fix(canvas): 画布同步 - 素材缺失时可强制覆盖并恢复文本与连线顺序

云端缺少素材时可用本地版强制覆盖,只清失效媒体引用。文本节点不再把资源地址当正文,合并时保留引用槽位顺序。
This commit is contained in:
ddcat666
2026-10-10 20:19:54 +08:00
parent 1bbc95b996
commit dd66be15cb
12 changed files with 442 additions and 46 deletions

View File

@@ -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`;大画布增量超过事件上限时改为完整快照刷新通知,避免审批执行事务回滚。

View File

@@ -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 <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
// 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 <ImageNodeContent {...props} />;
}
@@ -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 <CachedResourceImage storageKey={node.metadata?.storageKey} src={node.metadata?.previewContent || node.metadata?.content} alt={node.title} loading="lazy" decoding="async" draggable={false} className="pointer-events-none block size-full select-none object-contain" fallback={<CanvasNodeShellContent node={node} theme={theme} />} />;
return <CachedResourceImage storageKey={node.metadata?.storageKey} src={node.metadata?.previewContent || node.metadata?.content} variant="thumbnail" alt={node.title} loading="lazy" decoding="async" draggable={false} className="pointer-events-none block size-full select-none object-contain" fallback={<CanvasNodeShellContent node={node} theme={theme} />} />;
}
if (node.type === CanvasNodeType.Video && (node.metadata?.content || node.metadata?.storageKey)) {
return <CanvasVideoPreviewImage node={node} alt={node.title} loading="lazy" decoding="async" draggable={false} className="pointer-events-none block size-full select-none object-contain" fallback={<CanvasNodeShellContent node={node} theme={theme} />} />;
@@ -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 (
<div className="flex h-full w-full flex-col overflow-hidden pt-10">
@@ -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
/>
) : (
<div className="thin-scrollbar block h-full w-full select-text overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pb-4 pt-0 font-mono" style={textStyle} onWheel={(event) => event.stopPropagation()}>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
{textContent || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
</div>
)}
</div>

View File

@@ -87,6 +87,52 @@ function mergeItems<T extends { id: string }>(items: T[], changes: Change<T>[]):
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<string, unknown>;
const before = previous as CanvasProject & Record<string, unknown>;
const after = incoming as CanvasProject & Record<string, unknown>;

View File

@@ -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 ? (
<div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span>
<span>
{missingResources
? "可点击“用本地版强制覆盖云端”;操作前会自动保留本地草稿,失效图片节点会保留布局和提示词。"
: retryScheduled
? "网络恢复后会自动重试;也可以点击“立即重试”。"
: "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
</span>
</div>
) : localError ? (
<div className="canvas-sync-panel__callout">
@@ -150,19 +159,39 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
) : null}
<div className="canvas-sync-panel__actions">
{cloudError ? (
<Button
type="primary"
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() =>
void run(() => retryRemoteUserDataSync(projectId))
.then(() => setStatusOpen(false))
.catch(() => undefined)
}
>
立即重试云端同步
</Button>
<>
{!missingResources ? (
<Button
type="primary"
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() =>
void run(() => retryRemoteUserDataSync(projectId))
.then(() => setStatusOpen(false))
.catch(() => undefined)
}
>
立即重试云端同步
</Button>
) : null}
{missingResources ? (
<Button
type="primary"
danger
block
icon={<UploadCloud className="size-3.5" />}
loading={busy}
onClick={() =>
void run(() => overwriteRemoteCanvasProject(projectId))
.then(() => setStatusOpen(false))
.catch(() => undefined)
}
>
修复并强制覆盖云端
</Button>
) : null}
</>
) : localError ? (
<Button
type="primary"

View File

@@ -1774,7 +1774,9 @@ function CanvasViewportPage() {
try {
const content = node.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
const resourceId = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio].includes(node.type as CanvasNodeType)
? resourceIdFromStorageKey(node.metadata?.storageKey)
: "";
// Resource-backed media must use the central access contract. This keeps
// copy operations on the configured CDN/OSS URL instead of copying the
// platform file endpoint or a stale URL persisted in canvas metadata.

View File

@@ -394,7 +394,7 @@ export function useCanvasProjectLifecycle({
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
try {
const result = await forceOverwriteRemoteCanvasSync(projectId);
message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
message.success(result.clearedResources > 0 ? `已强制保存,移除了 ${result.clearedResources} 个失效素材引用;本地草稿已保留` : result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
} catch (error) {
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
return false;

View File

@@ -0,0 +1,114 @@
import { refreshResource, type RemoteResource } from "@/services/api/resources";
import { ApiError } from "@/services/api/request";
import { preserveCanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
// Keep these fields aligned with internal/assets/references.go. Prompts and
// ordinary text containing a resource URL are not media locators.
const locatorFields = new Set([
"storageKey",
"content",
"previewContent",
"drawingPreviewStorageKey",
"drawingPreviewUrl",
"url",
"dataUrl",
"coverUrl",
"imageUrl",
"videoUrl",
"audioUrl",
"referenceUrl",
"referenceUrls",
"artifactRef",
"providerArtifactRef",
]);
const idFields = new Set(["resourceId", "resourceIds", "sampleResourceId", "referenceResourceId", "referenceResourceIds"]);
const textFields = new Set(["prompt", "composerContent", "title", "text", "description", "fileName", "name"]);
function resourceId(field: string, value: unknown, allowUnknownField = false): string | undefined {
if (typeof value !== "string") return;
const text = value.trim();
if (idFields.has(field)) return /^[A-Za-z0-9_-]{1,80}$/.test(text) ? text : undefined;
if ((!locatorFields.has(field) && !allowUnknownField) || textFields.has(field)) return;
return /^resource:([A-Za-z0-9_-]{1,80})$/.exec(text)?.[1] || /^(?:https?:\/\/[^/]+)?\/(?:api\/)?resources\/([A-Za-z0-9_-]{1,80})\/file(?:[?#].*)?$/.exec(text)?.[1];
}
export function canvasResourceIds(value: unknown, field = "", ids = new Set<string>()): Set<string> {
const id = resourceId(field, value);
if (id) ids.add(id);
else if (Array.isArray(value)) for (const child of value) canvasResourceIds(child, field, ids);
else if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) canvasResourceIds(child, key, ids);
return ids;
}
/** Only called after explicit overwrite and a durable backup of the original. */
export function clearMissingCanvasResources<T>(value: T, missing: ReadonlySet<string>, field = "", allowUnknownField = false): T {
const id = resourceId(field, value, allowUnknownField);
if (id && missing.has(id)) return undefined as T;
if (Array.isArray(value)) return value.map((child) => clearMissingCanvasResources(child, missing, field, allowUnknownField)).filter((child) => child !== undefined) as T;
if (!value || typeof value !== "object") return value;
const record = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
const cleaned = clearMissingCanvasResources(child, missing, key, allowUnknownField);
if (cleaned !== undefined) next[key] = cleaned;
}
if (
["storageKey", "content", "url", "dataUrl"].some((key) => {
const id = resourceId(key, record[key], allowUnknownField);
return id && missing.has(id);
})
)
delete next.assetId;
if (["image", "video", "audio"].includes(String(record.type)) && next.metadata && typeof next.metadata === "object") {
const metadata = next.metadata as Record<string, unknown>;
if (!metadata.storageKey && !metadata.content && canvasResourceIds(record.metadata).size > canvasResourceIds(metadata).size) {
metadata.status = "idle";
}
}
return next as T;
}
export async function repairMissingResourcesForOverwrite(
projectIds?: Set<string>,
lookup: (id: string) => Promise<RemoteResource> = refreshResource,
preserve: (project: CanvasProject, scope: string) => Promise<unknown> = preserveCanvasSyncDraft,
): Promise<number> {
const scope = getActiveUserScope();
const selected = (project: CanvasProject) => !projectIds || projectIds.has(project.id);
const ids = [...canvasResourceIds(useCanvasStore.getState().projects.filter(selected))];
const missing = new Set<string>();
for (let offset = 0; offset < ids.length; offset += 8) {
await Promise.all(
ids.slice(offset, offset + 8).map(async (id) => {
try {
const resource = await lookup(id);
if (resource.status === "deleted" || resource.status === "failed") missing.add(id);
else if (resource.status !== "ready") throw new Error("素材仍在上传,请完成后再保存");
} catch (error) {
if (error instanceof ApiError && error.status === 404) missing.add(id);
else throw error;
}
}),
);
}
if (getActiveUserScope() !== scope) throw new Error("账号已切换,已停止修复保存");
if (!missing.size) return 0;
await clearMissingCanvasResourceIdsForOverwrite(projectIds, missing, preserve);
return missing.size;
}
/** Server validation is authoritative. This fallback handles locator shapes
* added on the server before the client collector knows about them. */
export async function clearMissingCanvasResourceIdsForOverwrite(projectIds: Set<string> | undefined, missing: ReadonlySet<string>, preserve: (project: CanvasProject, scope: string) => Promise<unknown> = preserveCanvasSyncDraft): Promise<number> {
const scope = getActiveUserScope();
const selected = (project: CanvasProject) => !projectIds || projectIds.has(project.id);
const projects = useCanvasStore.getState().projects.filter(selected);
for (const project of projects) await preserve(project, scope);
if (getActiveUserScope() !== scope) throw new Error("账号已切换,已停止修复保存");
if (projects.some((project) => useCanvasStore.getState().openProject(project.id) !== project)) throw new Error("画布仍在编辑,请重新点击覆盖保存");
const repaired = new Map(projects.map((project) => [project.id, { ...clearMissingCanvasResources(project, missing, "", true), updatedAt: new Date().toISOString() }]));
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => repaired.get(project.id) ?? project) }));
return projects.length;
}

View File

@@ -2,7 +2,7 @@
//
// 同一份内联数据按内容摘要去重上传;上传失败时整批同步失败并进入重试,不写入半成品。
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { getResourceBlob, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { getImageBlob } from "@/services/image-storage";
import { getMediaBlob } from "@/services/file-storage";
@@ -33,51 +33,91 @@ export function collectLocalMediaKeys(value: unknown, set = new Set<string>()):
return [...set];
}
export async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void): Promise<T> {
/**
* 修复早期版本已经把文本正文保存成资源 URL 的画布数据。
* 上传文本时 prompt 与正文相同,优先使用 prompt,避免一次额外的资源下载;
* 其他旧数据再从同一 storageKey 读取原始文件。
*/
export async function restoreRemoteTextNodeContents<T>(value: T, readText: (storageKey: string) => Promise<string | null> = readRemoteTextResource): Promise<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
if (!Array.isArray(record.nodes)) return value;
const nodes = await Promise.all(
record.nodes.map(async (item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const node = item as Record<string, unknown>;
if (node.type !== "text" && node.type !== "markdown") return item;
const metadata = node.metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return item;
const nextMetadata = { ...(metadata as Record<string, unknown>) };
const storageKey = typeof nextMetadata.storageKey === "string" ? nextMetadata.storageKey : "";
const content = typeof nextMetadata.content === "string" ? nextMetadata.content : "";
if (!resourceIdFromStorageKey(storageKey) || !isResourceUrl(content)) return item;
const prompt = typeof nextMetadata.prompt === "string" ? nextMetadata.prompt : "";
const restored = prompt && !isResourceUrl(prompt) ? prompt : await readText(storageKey);
if (!restored) return item;
return { ...node, metadata: { ...nextMetadata, content: restored, ...(prompt && !isResourceUrl(prompt) ? {} : { prompt: restored }) } };
}),
);
return { ...record, nodes } as T;
}
async function readRemoteTextResource(storageKey: string) {
try {
return (await getResourceBlob(storageKey))?.text() || null;
} catch {
return null;
}
}
export async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void, preserveTextContent = false): Promise<T> {
if (!value || typeof value !== "object") return value;
// 文本节点也会携带 file 资源的 storageKey。这个 key 用于素材归属和
// Agent 读取文件,不能因此把节点正文替换成 /api/resources/.../file。
const currentPreserveTextContent = preserveTextContent || (typeof (value as { type?: unknown }).type === "string" && (value as unknown as { type: string }).type === "text");
if (Array.isArray(value)) {
const result: unknown[] = [];
for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded, onUploaded));
for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded, onUploaded, currentPreserveTextContent));
return result as T;
}
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
next[key] = await ensureRemoteResourceReferences(child, uploaded, onUploaded);
next[key] = await ensureRemoteResourceReferences(child, uploaded, onUploaded, currentPreserveTextContent);
}
const storageKey = typeof next.storageKey === "string" ? next.storageKey : "";
const remoteResourceId = resourceIdFromStorageKey(storageKey);
if (remoteResourceId) return applyResourceReference(next, storageKey) as T;
if (remoteResourceId) return applyResourceReference(next, storageKey, currentPreserveTextContent) as T;
if (!isLocalStorageKey(storageKey)) {
const inline = inlineMediaDataUrl(next);
if (!inline) return next as T;
const identity = await inlineMediaUploadIdentity(inline);
const cached = uploaded.get(identity);
if (cached) return applyResourceReference(next, cached) as T;
if (cached) return applyResourceReference(next, cached, currentPreserveTextContent) as T;
const resourceStorage = await uploadInlineDataUrl(inline, identity);
uploaded.set(identity, resourceStorage);
onUploaded?.();
return applyResourceReference(next, resourceStorage) as T;
return applyResourceReference(next, resourceStorage, currentPreserveTextContent) as T;
}
const cached = uploaded.get(storageKey);
if (cached) return applyResourceReference(next, cached) as T;
if (cached) return applyResourceReference(next, cached, currentPreserveTextContent) as T;
const resourceStorage = await uploadLocalStorageKey(storageKey, next);
uploaded.set(storageKey, resourceStorage);
onUploaded?.();
return applyResourceReference(next, resourceStorage) as T;
return applyResourceReference(next, resourceStorage, currentPreserveTextContent) as T;
}
export function applyResourceReference(payload: Record<string, unknown>, storageKey: string) {
export function applyResourceReference(payload: Record<string, unknown>, storageKey: string, preserveTextContent = false) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) {
throw new Error(`远端资源引用无效:${storageKey}`);
}
const url = resourceFileUrl(resourceId);
payload.storageKey = storageKey;
for (const key of ["content", "dataUrl", "url", "coverUrl"]) {
for (const key of preserveTextContent ? [] : ["content", "dataUrl", "url", "coverUrl"]) {
if (typeof payload[key] === "string") payload[key] = url;
}
return payload;

View File

@@ -25,10 +25,11 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { repairMissingCanvasAssets, repairMissingCanvasVideoPreviews, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
import { applyAgentCanvasPatch, mergeAgentCanvasEditor, mergeThreeWayValue, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
import { collectLocalMediaKeys, ensureRemoteResourceReferences } from "./user-data-sync-media";
import { collectLocalMediaKeys, ensureRemoteResourceReferences, restoreRemoteTextNodeContents } from "./user-data-sync-media";
import { clearMissingCanvasResourceIdsForOverwrite, repairMissingResourcesForOverwrite } from "./canvas-missing-resources";
export { numberValue } from "./user-data-sync-media";
@@ -118,7 +119,7 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
let remote: CanvasProject;
try {
const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined;
remote = (await getRemoteCanvasProject(id, knownRemote)).project;
remote = await restoreRemoteTextNodeContents((await getRemoteCanvasProject(id, knownRemote)).project);
} catch (error) {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().openProject(id);
@@ -193,7 +194,7 @@ export async function refreshCanvasAfterAgent(id: string) {
return withRemoteUserDataSyncExclusive(async () => {
if (!activeRemoteUserId) throw new Error("请先登录再刷新 Agent 画布结果");
const knownRemote = verifiedProjects.has(id) ? acknowledgedProjects.get(id) : undefined;
const { project } = await getRemoteCanvasProject(id, knownRemote);
const project = await restoreRemoteTextNodeContents((await getRemoteCanvasProject(id, knownRemote)).project);
if (epoch !== sessionEpoch) throw new Error("账号已切换");
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
const baseline = acknowledgedProjects.get(id);
@@ -729,7 +730,13 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
await waitForRemoteProjectLoads();
if (syncPromise) {
syncQueued = true;
await syncPromise;
try {
await syncPromise;
} catch (error) {
// A failed automatic save must not cancel a later explicit repair.
if (!options.force && !options.repairMissingResources) throw error;
}
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
assertNoConflict();
// The in-flight drain may have started before this explicit repair/force
// request. Re-enter after it settles so the user's intent is not lost.
@@ -762,16 +769,12 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
}
}
/**
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
*/
export async function overwriteRemoteCanvasProject(projectId: string) {
if (!projectId.trim()) throw new Error("缺少画布 ID,无法覆盖云端");
await saveRemoteUserDataNow({ projectId, force: true });
return forceOverwriteRemoteCanvasSync(projectId);
}
export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise<CanvasAssetRebindResult> {
export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise<CanvasAssetRebindResult & { clearedResources: number }> {
const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试");
requireRemoteUserDataBaseline();
@@ -780,7 +783,8 @@ export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promis
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
requireRemoteUserDataBaseline();
const selectedProjectIds = projectId === undefined ? undefined : new Set([projectId]);
await repairMissingCanvasVideoPreviews(selectedProjectIds);
if (projectId !== undefined && !useCanvasStore.getState().openProject(projectId)) throw new Error("画布不存在,无法覆盖云端");
const clearedResources = await repairMissingResourcesForOverwrite(selectedProjectIds);
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
const projects = useCanvasStore.getState().projects;
const claimedIds = [...collectCanvasMediaAssetIds(selectedProjectIds ? projects.filter((project) => selectedProjectIds.has(project.id)) : projects)];
@@ -794,9 +798,23 @@ export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promis
const merged = [...remoteAssets, ...useAssetStore.getState().assets.filter((asset) => !remoteById.has(asset.id))];
const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged), selectedProjectIds);
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
return result;
return { ...result, clearedResources };
});
await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
try {
await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
} catch (error) {
// The server is authoritative about resource locators. Retry once with
// the IDs returned in its diagnostic in case the client did not yet
// recognize a newly introduced locator field.
if (!(error instanceof ApiError) || error.reason !== "canvas_history_resources_missing") throw error;
const ids = Array.isArray(error.details?.resourceIds)
? error.details.resourceIds.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
if (!ids.length) throw error;
await clearMissingCanvasResourceIdsForOverwrite(projectId === undefined ? undefined : new Set([projectId]), new Set(ids));
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
}
return rebind;
}

View File

@@ -44,6 +44,32 @@ test("new nodes and reference connections are applied atomically", () => {
expect(result.connections).toEqual([edge]);
});
test("Agent refresh applies server reference order when local connections were untouched", () => {
const imageOne = { id: "image-one", fromNodeId: "image-one", toNodeId: node.id };
const imageTwo = { id: "image-two", fromNodeId: "image-two", toNodeId: node.id };
const characterOne = { id: "character-one", fromNodeId: "character-one", toNodeId: node.id };
const characterTwo = { id: "character-two", fromNodeId: "character-two", toNodeId: node.id };
const previous = { ...project, connections: [characterOne, characterTwo, imageOne, imageTwo] };
const incoming = { ...previous, revision: 2, connections: [imageOne, imageTwo, characterOne, characterTwo] };
const merged = mergeAgentCanvasEditor(previous, incoming, previous.nodes, previous.connections, previous);
expect(merged.connections.map((connection) => connection.id)).toEqual(["image-one", "image-two", "character-one", "character-two"]);
});
test("Agent refresh preserves moving the first reference to the fifth position locally", () => {
const roleOne = { id: "role-one", fromNodeId: "role-one", toNodeId: node.id };
const imageTwo = { id: "image-two", fromNodeId: "image-two", toNodeId: node.id };
const roleThree = { id: "role-three", fromNodeId: "role-three", toNodeId: node.id };
const imageFour = { id: "image-four", fromNodeId: "image-four", toNodeId: node.id };
const imageFive = { id: "image-five", fromNodeId: "image-five", toNodeId: node.id };
const previous = { ...project, connections: [roleOne, imageTwo, roleThree, imageFour, imageFive] };
const incoming = { ...previous, revision: 2, connections: [imageFive, roleOne, imageTwo, roleThree, imageFour] };
const local = { ...previous, connections: [imageTwo, roleThree, imageFour, imageFive, roleOne] };
const merged = mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local);
expect(merged.connections.map((connection) => connection.id)).toEqual(["image-two", "role-three", "image-four", "image-five", "role-one"]);
});
test("deleted nodes and a newer task binding are not overwritten", () => {
expect(() => applyAgentCanvasPatch({ ...project, nodes: [] }, patch)).toThrow("冲突");
// A replacement task can have the same running status; its task identity is a fence.

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { canvasResourceIds, clearMissingCanvasResources } from "@/services/canvas-missing-resources";
describe("canvas missing resource overwrite", () => {
test("collects nested resource locators without treating prompt text as media", () => {
const value = {
nodes: [{ id: "node-1", metadata: { storageKey: "resource:gone", prompt: "resource:keep-as-text", videoPreview: { url: "/api/resources/poster/file" } } }],
};
expect(canvasResourceIds(value)).toEqual(new Set(["gone", "poster"]));
});
test("removes only invalid media fields and asset binding while keeping node content", () => {
const value = {
id: "canvas",
nodes: [{ id: "node-1", type: "image", metadata: { assetId: "asset-1", storageKey: "resource:gone", content: "/api/resources/gone/file", prompt: "keep this prompt", status: "success" } }],
timeline: { clips: [{ id: "clip", directMedia: { kind: "image", assetId: "asset-1", storageKey: "resource:gone", url: "/api/resources/gone/file" } }] },
};
const repaired = clearMissingCanvasResources(value, new Set(["gone"]));
expect(repaired).toEqual({
id: "canvas",
nodes: [{ id: "node-1", type: "image", metadata: { prompt: "keep this prompt", status: "idle" } }],
timeline: { clips: [{ id: "clip", directMedia: { kind: "image" } }] },
});
});
test("server reported locator fields are also cleared during the fallback", () => {
const value = { nodes: [{ id: "node-1", metadata: { assetId: "asset-1", futureLocator: "resource:gone", prompt: "keep" } }] };
expect(clearMissingCanvasResources(value, new Set(["gone"]), "", true)).toEqual({
nodes: [{ id: "node-1", metadata: { assetId: "asset-1", prompt: "keep" } }],
});
});
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import { ensureRemoteResourceReferences, restoreRemoteTextNodeContents } from "@/services/user-data-sync-media";
describe("画布资源引用同步", () => {
test("文本节点保留正文,只同步资源定位键", async () => {
const node = {
type: "text",
metadata: {
content: "这是上传文件的正文",
prompt: "这是上传文件的正文",
storageKey: "resource:text-file",
mimeType: "text/plain",
},
};
const synced = await ensureRemoteResourceReferences(node);
expect(synced.metadata.content).toBe(node.metadata.content);
expect(synced.metadata.prompt).toBe(node.metadata.prompt);
expect(synced.metadata.storageKey).toBe("resource:text-file");
});
test("媒体节点仍将展示字段转换为资源地址", async () => {
const node = {
type: "image",
metadata: {
content: "blob:image",
storageKey: "resource:image-file",
},
};
const synced = await ensureRemoteResourceReferences(node);
expect(synced.metadata.content).toContain("/resources/image-file/file");
});
test("加载旧画布时恢复已被保存成资源 URL 的文本正文", async () => {
const project = {
nodes: [
{
type: "text",
metadata: {
content: "/api/resources/text-file/file",
prompt: "上传文件的原始正文",
storageKey: "resource:text-file",
},
},
],
};
const restored = await restoreRemoteTextNodeContents(project, async () => null);
expect(restored.nodes[0].metadata.content).toBe("上传文件的原始正文");
});
test("没有可恢复的 prompt 时从资源文件读取正文", async () => {
const project = {
nodes: [{ type: "text", metadata: { content: "/api/resources/text-file/file", storageKey: "resource:text-file" } }],
};
const restored = await restoreRemoteTextNodeContents(project, async () => "从资源恢复的正文");
expect(restored.nodes[0].metadata.content).toBe("从资源恢复的正文");
expect(restored.nodes[0].metadata.prompt).toBe("从资源恢复的正文");
});
});