fix(canvas): 画布同步 - 素材缺失时可强制覆盖并恢复文本与连线顺序
云端缺少素材时可用本地版强制覆盖,只清失效媒体引用。文本节点不再把资源地址当正文,合并时保留引用槽位顺序。
This commit is contained in:
@@ -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`;大画布增量超过事件上限时改为完整快照刷新通知,避免审批执行事务回滚。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
114
web/src/services/canvas-missing-resources.ts
Normal file
114
web/src/services/canvas-missing-resources.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
33
web/test/canvas-missing-resources.test.ts
Normal file
33
web/test/canvas-missing-resources.test.ts
Normal 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" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
67
web/test/user-data-sync-media.test.ts
Normal file
67
web/test/user-data-sync-media.test.ts
Normal 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("从资源恢复的正文");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user