event.preventDefault()}
className={`absolute inset-0 ${className}`}
@@ -691,9 +704,10 @@ export function useNodeResourceUrl(node: CanvasNodeData, eager: boolean, variant
return { url: current.url, loading: current.loading, originalWidth: isThumbnail ? current.originalWidth : undefined, originalHeight: isThumbnail ? current.originalHeight : undefined };
}
-export function useNearViewport(ref: RefObject) {
+export function useNearViewport(ref: RefObject, enabled = true) {
const [nearViewport, setNearViewport] = useState(false);
useEffect(() => {
+ if (!enabled || nearViewport) return;
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
@@ -710,7 +724,7 @@ export function useNearViewport(ref: RefObject) {
);
observer.observe(element);
return () => observer.disconnect();
- }, [ref]);
+ }, [enabled, nearViewport, ref]);
return nearViewport;
}
diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx
index c3ee9f636..2f3f5a7bc 100644
--- a/web/src/components/canvas/canvas-node-toolbar.tsx
+++ b/web/src/components/canvas/canvas-node-toolbar.tsx
@@ -7,7 +7,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
-import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
+import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
@@ -17,7 +17,7 @@ import { producedModelLabel } from "@/lib/canvas/produced-model";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
-import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
+import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type Position, type ViewportTransform } from "@/types/canvas";
import { buildImageToolbarTools } from "./canvas-image-toolbar-tools";
import { CanvasGridSplitPicker } from "./canvas-grid-split-picker";
@@ -163,28 +163,41 @@ export function CanvasNodeToolbar({
}
let disposed = false;
let queued = false;
+ let liveViewport = viewport;
+ let liveDragOffset: Position | null = null;
let containerRect = container.getBoundingClientRect();
let toolbarWidth = toolbarRef.current?.offsetWidth || 0;
let toolbarHeight = toolbarRef.current?.offsetHeight || 44;
const update = () => {
- const nodeRect = element.getBoundingClientRect();
- const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
+ // 视口预览帧已经修改了世界层 transform;直接用节点世界坐标计算,
+ // 避免随后读取 getBoundingClientRect() 强制同步布局。
+ const scale = Math.max(liveViewport.k, 0.05);
+ const offsetX = liveDragOffset?.x || 0;
+ const offsetY = liveDragOffset?.y || 0;
+ const nodeLeft = liveViewport.x + (node.position.x + offsetX) * scale;
+ const nodeTop = liveViewport.y + (node.position.y + offsetY) * scale;
+ const nodeWidth = node.width * scale;
+ const preferredLeft = nodeLeft + nodeWidth / 2;
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
- const above = nodeRect.top - containerRect.top - 30;
+ const above = nodeTop - 30;
let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8));
- for (const panel of container.querySelectorAll("[data-canvas-node-panel]")) {
- const panelRect = panel.getBoundingClientRect();
- const panelLeft = panelRect.left - containerRect.left;
- const panelRight = panelRect.right - containerRect.left;
- const panelTop = panelRect.top - containerRect.top;
- const panelBottom = panelRect.bottom - containerRect.top;
- if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
- if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
- else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
- else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
- else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
+ // 交互期只走轻量定位;面板碰撞读取会在 transform 后触发 layout,
+ // 提交完成后的下一次更新再恢复精确避让。
+ if (container.dataset.canvasViewportInteracting !== "true") {
+ for (const panel of container.querySelectorAll("[data-canvas-node-panel]")) {
+ const panelRect = panel.getBoundingClientRect();
+ const panelLeft = panelRect.left - containerRect.left;
+ const panelRight = panelRect.right - containerRect.left;
+ const panelTop = panelRect.top - containerRect.top;
+ const panelBottom = panelRect.bottom - containerRect.top;
+ if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
+ if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
+ else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
+ else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
+ else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
+ }
}
if (toolbarRef.current) {
toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`;
@@ -192,7 +205,8 @@ export function CanvasNodeToolbar({
}
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
};
- const scheduleUpdate = () => {
+ const scheduleUpdate = (nextViewport?: ViewportTransform) => {
+ if (nextViewport) liveViewport = nextViewport;
if (queued || disposed) return;
queued = true;
queueMicrotask(() => {
@@ -213,11 +227,16 @@ export function CanvasNodeToolbar({
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate);
+ const unsubscribeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
+ liveDragOffset = preview?.nodeIds.has(node.id) ? { x: preview.x, y: preview.y } : null;
+ scheduleUpdate();
+ });
window.addEventListener("resize", measure);
return () => {
disposed = true;
resizeObserver.disconnect();
unsubscribeViewport();
+ unsubscribeDrag();
window.removeEventListener("resize", measure);
};
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);
diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx
index d0d90c6aa..d5f6576bc 100644
--- a/web/src/components/canvas/canvas-node.tsx
+++ b/web/src/components/canvas/canvas-node.tsx
@@ -34,7 +34,7 @@ type CanvasNodeProps = {
connectionApproach?: Position;
forceInputVisible?: boolean;
showImageInfo: boolean;
- reduceMediaEffects?: boolean;
+ disableMediaEffects?: boolean;
readOnly?: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
@@ -60,7 +60,6 @@ type CanvasNodeProps = {
onRetry?: (node: CanvasNodeData) => void;
onReloadResource?: (node: CanvasNodeData) => void;
onOpenTaskDetails?: (node: CanvasNodeData) => void;
- onOpenVersions?: (node: CanvasNodeData) => void;
onViewImage?: (node: CanvasNodeData) => void;
onReplaceMedia?: (node: CanvasNodeData) => void;
onOpenTextEditor?: (node: CanvasNodeData) => void;
@@ -83,7 +82,7 @@ export const CanvasNode = React.memo(function CanvasNode({
connectionApproach,
forceInputVisible = false,
showImageInfo,
- reduceMediaEffects = false,
+ disableMediaEffects = false,
readOnly = false,
resourceLabel,
mentionReferences = [],
@@ -109,7 +108,6 @@ export const CanvasNode = React.memo(function CanvasNode({
onRetry,
onReloadResource,
onOpenTaskDetails,
- onOpenVersions,
onViewImage,
onOpenTextEditor,
onOpenPrevis,
@@ -189,7 +187,7 @@ export const CanvasNode = React.memo(function CanvasNode({
if (next !== data.title) onTitleChange?.(data.id, next);
};
- const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
+ const connectionTilt = isConnectionTarget && !disableMediaEffects && !dragOffset
? canvasConnectionTilt(data, connectionApproach) : undefined;
return (
@@ -317,7 +315,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onReloadResource={onReloadResource}
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
- reduceMediaEffects={reduceMediaEffects}
+ disableMediaEffects={disableMediaEffects}
mediaActive={mediaActive}
onMediaPlayRequest={onMediaPlayRequest}
/>
@@ -342,26 +340,8 @@ export const CanvasNode = React.memo(function CanvasNode({
) : null}
- {showChrome && data.metadata?.versionLabel ? (
-
- ) : null}
{showChrome && showStatusTrack ? (
-
+
{resourceLabel && data.type !== CanvasNodeType.Image ?
: null}
{hasMediaContent && !readOnly ?
: null}
{isBatchRoot ?
onToggleBatch?.(data.id)} /> : null}
@@ -436,7 +416,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.connectionApproach?.y === next.connectionApproach?.y &&
previous.forceInputVisible === next.forceInputVisible &&
previous.showImageInfo === next.showImageInfo &&
- previous.reduceMediaEffects === next.reduceMediaEffects &&
+ previous.disableMediaEffects === next.disableMediaEffects &&
previous.readOnly === next.readOnly &&
previous.resourceLabel === next.resourceLabel &&
previous.mentionReferences === next.mentionReferences &&
@@ -464,7 +444,6 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.onRetry === next.onRetry &&
previous.onReloadResource === next.onReloadResource &&
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
- previous.onOpenVersions === next.onOpenVersions &&
previous.onViewImage === next.onViewImage &&
previous.onReplaceMedia === next.onReplaceMedia &&
previous.onOpenTextEditor === next.onOpenTextEditor &&
diff --git a/web/src/components/canvas/canvas-version-compare-modal.tsx b/web/src/components/canvas/canvas-version-compare-modal.tsx
deleted file mode 100644
index 326136151..000000000
--- a/web/src/components/canvas/canvas-version-compare-modal.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Button, Modal } from "antd";
-import { Check, Star } from "lucide-react";
-
-import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
-import { producedModelLabel } from "@/lib/canvas/produced-model";
-import { canvasThemes } from "@/lib/canvas-theme";
-import { useEffectiveConfig } from "@/stores/use-config-store";
-import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
-import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
-
-export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimary, onFocus }: { open: boolean; versions: CanvasNodeData[]; onClose: () => void; onSetPrimary: (nodeId: string) => void; onFocus: (nodeId: string) => void }) {
- const theme = canvasThemes[useActiveTheme()];
- const config = useEffectiveConfig();
- const modalWidth = Math.min(1180, Math.max(440, 112 + versions.length * 340));
- return (
-
-
- {versions.map((node) => {
- const producedModelText = producedModelLabel(config, node.metadata?.producedModel);
- return (
-
-
-
{node.metadata?.versionLabel || "-"}{node.title}
- {node.metadata?.versionPrimary ?
主版本 : null}
-
-
-
- {producedModelText ?
: null}
-
-
提示词
{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}
-
} onClick={() => onSetPrimary(node.id)}>{node.metadata?.versionPrimary ? "当前主版本" : "设为主版本"}
-
-
- );
- })}
-
-
- );
-}
-
-function Info({ label, value }: { label: string; value: string }) {
- return {label}{value}
;
-}
diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx
index c8a7ec7d1..21aea2da3 100644
--- a/web/src/components/canvas/canvas-workspace-overlays.tsx
+++ b/web/src/components/canvas/canvas-workspace-overlays.tsx
@@ -138,8 +138,9 @@ export function CanvasNodePanelOverlay({
liveViewport = nextViewport;
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
panel.style.width = `${nextWidth}px`;
- const nodeElement = container.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`);
- const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
+ // 视口预览期间世界层刚写入 transform,读取节点布局会强制浏览器同步
+ // layout。节点位置和拖拽偏移已经在这里可用,直接用世界坐标计算浮层位置。
+ const position = getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
};
update(viewport);
@@ -241,8 +242,7 @@ export function CanvasConnectionCreateMenu({
const menu = menuRef.current;
if (!container || !menu) return;
const update = (nextViewport: ViewportTransform) => {
- const containerBounds = container.getBoundingClientRect();
- const position = getConnectionMenuPosition(pending.position, nextViewport, { width: containerBounds.width, height: containerBounds.height }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap);
+ const position = getConnectionMenuPosition(pending.position, nextViewport, { width: container.clientWidth, height: container.clientHeight }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap);
menu.style.left = `${position.left}px`;
menu.style.top = `${position.top}px`;
};
@@ -441,17 +441,6 @@ function getConnectionMenuPosition(position: Position, viewport: ViewportTransfo
};
}
-function getAttachedNodePanelPosition(nodeElement: HTMLElement, container: HTMLElement, panelWidth: number) {
- const gap = 10;
- const nodeRect = nodeElement.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
- return {
- left: nodeRect.left - containerRect.left + nodeRect.width / 2 - panelWidth / 2,
- top: nodeRect.bottom - containerRect.top + gap,
- placement: "below" as const,
- };
-}
-
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, _viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
const gap = 10;
const offsetX = dragOffset?.x || 0;
diff --git a/web/src/components/canvas/nodes/panorama-node.tsx b/web/src/components/canvas/nodes/panorama-node.tsx
index 8e29e5541..e65918e77 100644
--- a/web/src/components/canvas/nodes/panorama-node.tsx
+++ b/web/src/components/canvas/nodes/panorama-node.tsx
@@ -11,7 +11,7 @@ import type { CanvasNodeData } from "@/types/canvas";
type PanoramaNodeContentProps = {
node: CanvasNodeData;
theme: CanvasTheme;
- reduceMediaEffects?: boolean;
+ disableMediaEffects?: boolean;
};
/** 节点外壳调用查看器能力的最小控制面;实现由 PanoramaViewport 注入。 */
@@ -44,15 +44,13 @@ function clampFov(value: number) {
* 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
* 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose——
* 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
- * 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
- * reduceMediaEffects 时只给静态预览。
- * 4. **截图/四宫格导出走 preserveDrawingBuffer + 同步重渲染**。captureSquare
+ * 3. **截图/四宫格导出走 preserveDrawingBuffer + 同步重渲染**。captureSquare
* 临时把 renderer 切到方形画幅再恢复,不依赖 rAF 时序;全屏展开时同一时刻
* 只挂一个 viewer 实例(节点体退回静态预览),上下文数量不翻倍。
- * 5. **节点内不消费滚轮**,滚轮留给画布缩放;全屏覆盖层内画布缩放不存在,
+ * 4. **节点内不消费滚轮**,滚轮留给画布缩放;全屏覆盖层内画布缩放不存在,
* 才用滚轮调视场角。
*/
-export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
+export function PanoramaNodeContent({ node, theme, disableMediaEffects }: PanoramaNodeContentProps) {
const upstream = useUpstreamNodes(node.id);
const actions = useCanvasNodeActions();
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
@@ -142,9 +140,9 @@ export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: Panoram
- {failed ? "全景加载失败(图片可能不允许跨域读取)" : expanded ? "已展开全屏环视" : reduceMediaEffects ? "性能模式下仅显示预览" : `${projectionLabel} · 360° 全景`}
+ {failed ? "全景加载失败(图片可能不允许跨域读取)" : expanded ? "已展开全屏环视" : disableMediaEffects ? "仅显示预览" : `${projectionLabel} · 360° 全景`}
- {reduceMediaEffects || expanded ? null : (
+ {disableMediaEffects || expanded ? null : (
- {!collapsed ?
:
}
+ {!collapsed ?
+
+ : null}
+ {collapsed ?
+
+ : null}
);
}
-function WorkspaceSwitcher({ collapsed, onNavigate, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void; onCollapse: () => void }) {
+function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void; onCollapse: () => void }) {
const appearance = useAppearanceStore((state) => state.appearance);
if (collapsed) {
return (
-
-
-
-
+
+
+ } />
+
);
}
@@ -118,9 +122,6 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand, onCollapse }: { co
-
-
-
);
}
@@ -329,7 +330,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
-
+
{footer.length ?
{footer.map((item) => (
void handleLogout()} collapsed={collapsed} />
diff --git a/web/src/components/layout/workspace-top-bar.tsx b/web/src/components/layout/workspace-top-bar.tsx
index 0f9e066d3..31a3af576 100644
--- a/web/src/components/layout/workspace-top-bar.tsx
+++ b/web/src/components/layout/workspace-top-bar.tsx
@@ -15,6 +15,7 @@ import { openWorkspaceWallet } from "@/lib/workspace-wallet";
const PAGE_TITLES: Record = {
home: "创作",
create: "创作",
+ inspirations: "灵感",
projects: "短剧 Agent",
canvas: "自由画布",
tasks: "创作历史",
diff --git a/web/src/hooks/use-external-asset-sources.ts b/web/src/hooks/use-external-asset-sources.ts
index 7048c1cdf..74372bc81 100644
--- a/web/src/hooks/use-external-asset-sources.ts
+++ b/web/src/hooks/use-external-asset-sources.ts
@@ -25,11 +25,35 @@ export type ExternalAssetSourceState = {
export function useExternalAssetSources(open: boolean): ExternalAssetSourceState {
const hydrated = usePluginStore((state) => state.hydrated);
const installations = usePluginStore((state) => state.installations);
- const sources = useMemo(() => createHostedAssetSources(hydrated ? installations : []), [hydrated, installations]);
+ const needsEagle = open && hydrated && installations.some((installation) => installation.manifest.id === "eagle-asset-connector");
+ const [eagleLoaded, setEagleLoaded] = useState(false);
const [loadedSources, setLoadedSources] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
+ useEffect(() => {
+ if (!needsEagle) {
+ setEagleLoaded(true);
+ return;
+ }
+
+ let cancelled = false;
+ setEagleLoaded(false);
+ void import("@/lib/plugins/builtin/eagle")
+ .catch(() => undefined)
+ .finally(() => {
+ if (!cancelled) setEagleLoaded(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [needsEagle]);
+
+ const sources = useMemo(() => {
+ if (!hydrated || (needsEagle && !eagleLoaded)) return [];
+ return createHostedAssetSources(installations);
+ }, [eagleLoaded, hydrated, installations, needsEagle]);
+
useEffect(() => {
const controller = new AbortController();
if (!open || !sources.length) {
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/lib/canvas/agent-media-approval.ts b/web/src/lib/canvas/agent-media-approval.ts
index d398d72ef..59eac01f7 100644
--- a/web/src/lib/canvas/agent-media-approval.ts
+++ b/web/src/lib/canvas/agent-media-approval.ts
@@ -4,6 +4,34 @@ import type { CanvasNodeData } from "@/types/canvas";
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] };
+export type AgentVideoApproval = AgentImageApproval & { referenceTransientIds: string[]; sourceNodeId: string };
+
+export function agentVideoApproval(detail: Record): AgentVideoApproval | null {
+ const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
+ if ((call?.function?.name || detail.toolName) !== "generate_media") return null;
+ const raw = call?.function?.arguments ?? detail.arguments;
+ try {
+ const args = typeof raw === "string" ? JSON.parse(raw) : raw;
+ if (!args || args.mode !== "video" || typeof args.prompt !== "string") return null;
+ return {
+ logicalModelId: typeof args.logicalModelId === "string" ? args.logicalModelId : "",
+ channelId: typeof args.channelId === "string" ? args.channelId : "",
+ channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
+ size: typeof args.size === "string" ? args.size : "",
+ quality: typeof args.quality === "string" ? args.quality : "",
+ durationSeconds: typeof args.durationSeconds === "number" && args.durationSeconds > 0 ? args.durationSeconds : undefined,
+ videoGenerateAudio: typeof args.videoGenerateAudio === "boolean" ? args.videoGenerateAudio : undefined,
+ videoWatermark: typeof args.videoWatermark === "boolean" ? args.videoWatermark : undefined,
+ videoArkPrivateAssetUpload: typeof args.videoArkPrivateAssetUpload === "boolean" ? args.videoArkPrivateAssetUpload : undefined,
+ prompt: args.prompt,
+ referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
+ referenceTransientIds: Array.isArray(args.referenceTransientIds) ? args.referenceTransientIds.filter((id: unknown): id is string => typeof id === "string") : [],
+ sourceNodeId: typeof args.sourceNodeId === "string" ? args.sourceNodeId : "",
+ };
+ } catch {
+ return null;
+ }
+}
export function agentImageApproval(detail: Record): AgentImageApproval | null {
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
@@ -26,16 +54,22 @@ export function agentImageApproval(detail: Record): AgentImageA
}
}
-export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings): string {
- return selectableModelsByCapability(config, "image").find((model) => {
+export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings, capability: "image" | "video" = "image"): string {
+ return selectableModelsByCapability(config, capability).find((model) => {
if (settings.logicalModelId) return logicalModelIDForConfig({ ...config, model }) === settings.logicalModelId;
return resolveModelChannel(config, model).id === settings.channelId && modelOptionName(model) === settings.channelModelKey;
}) || "";
}
export function agentApprovalMatchesSettings(argumentsValue: unknown, settings: AgentMediaSettings): boolean {
- const approved = agentImageApproval({ toolName: "generate_media", arguments: argumentsValue });
- return Boolean(approved && (["logicalModelId", "channelId", "channelModelKey", "size", "quality"] as const).every((key) => (approved[key] || "") === (settings[key] || "")));
+ const detail = { toolName: "generate_media", arguments: argumentsValue };
+ const approved = agentImageApproval(detail) || agentVideoApproval(detail);
+ if (!approved) return false;
+ return (["logicalModelId", "channelId", "channelModelKey", "size", "quality", "durationSeconds", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload"] as const).every((key) => {
+ const left = approved[key];
+ const right = settings[key];
+ return (left === undefined ? "" : left) === (right === undefined ? "" : right);
+ });
}
export function agentApprovalModelSelection(config: AiConfig, model: string): Pick {
diff --git a/web/src/lib/canvas/canvas-generation-result.ts b/web/src/lib/canvas/canvas-generation-result.ts
index 66bfa16f1..f1a68eb48 100644
--- a/web/src/lib/canvas/canvas-generation-result.ts
+++ b/web/src/lib/canvas/canvas-generation-result.ts
@@ -29,7 +29,22 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
});
- // 同一任务的图片子节点使用稳定 ID;重放只补缺失项,不覆盖用户已移动、改名的图片。
+ const additionalById = new Map(additionalNodes.map((node) => [node.id, node]));
+ const committedWithAdditional = committed.map((node) => {
+ const refreshed = additionalById.get(node.id);
+ if (!refreshed) return node;
+ const manualSize = Boolean(node.metadata?.manualSize);
+ return {
+ ...refreshed,
+ title: node.title,
+ position: node.position,
+ width: manualSize ? node.width : refreshed.width,
+ height: manualSize ? node.height : refreshed.height,
+ };
+ });
const existingIds = new Set(current.map((node) => node.id));
- return [...committed, ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } }))];
+ return [
+ ...committedWithAdditional,
+ ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } })),
+ ];
}
diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts
index 5620b8c8d..e9ec1bae0 100644
--- a/web/src/lib/canvas/canvas-generation-task-sync.ts
+++ b/web/src/lib/canvas/canvas-generation-task-sync.ts
@@ -382,14 +382,17 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:image:${index}`;
const existing = nodes.find((item) => item.id === id);
- if (existing) {
- children.push(existing);
- continue;
- }
- const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
+ const child = await buildGenerationTaskNodeResult(
+ existing ? { ...node, ...existing, id, title: existing.title, position: existing.position, width: existing.width, height: existing.height } : { ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined },
+ task,
+ nodes,
+ index,
+ );
children.push({
...child,
- position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index),
+ ...(existing
+ ? { title: existing.title, position: existing.position, width: existing.metadata?.manualSize ? existing.width : child.width, height: existing.metadata?.manualSize ? existing.height : child.height }
+ : { position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index) }),
metadata: {
...child.metadata,
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
@@ -452,11 +455,6 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
const children: CanvasNodeData[] = [];
for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:layer:${index}`;
- const existing = nodes.find((item) => item.id === id);
- if (existing) {
- children.push(existing);
- continue;
- }
const image = images[index];
const stored: UploadedImage = image.storageKey
? {
@@ -469,6 +467,30 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
}
: await uploadImage(decoded.layers[index].blob);
if (stored.pendingRemoteUpload) throw new Error("图层仅缓存在本机,服务端保存未完成;请重试保存结果");
+ const existing = nodes.find((item) => item.id === id);
+ const metadata = commitProducedModel(
+ {
+ ...(existing?.metadata || {}),
+ ...imageMetadata(stored),
+ assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
+ batchRootId: node.id,
+ imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
+ model: node.metadata?.model || task.model,
+ producedModel: task.model,
+ },
+ task.model,
+ );
+ if (existing) {
+ children.push({
+ ...existing,
+ type: CanvasNodeType.Image,
+ width: existing.metadata?.manualSize ? existing.width : size.width,
+ height: existing.metadata?.manualSize ? existing.height : size.height,
+ position: existing.position,
+ metadata,
+ });
+ continue;
+ }
children.push({
id,
type: CanvasNodeType.Image,
@@ -478,17 +500,7 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
const position = imageGenerationChildPosition(node.position, size.width, size, index);
return { x: position.x + layoutPosition.x - firstPosition.x, y: position.y + layoutPosition.y - firstPosition.y };
})(),
- metadata: commitProducedModel(
- {
- ...imageMetadata(stored),
- assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
- batchRootId: node.id,
- imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
- model: node.metadata?.model || task.model,
- producedModel: task.model,
- },
- task.model,
- ),
+ metadata,
});
}
const composite = await uploadImage(decoded.composite);
diff --git a/web/src/lib/canvas/canvas-live-viewport.ts b/web/src/lib/canvas/canvas-live-viewport.ts
index cabfac325..856249dd9 100644
--- a/web/src/lib/canvas/canvas-live-viewport.ts
+++ b/web/src/lib/canvas/canvas-live-viewport.ts
@@ -48,9 +48,13 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
worldLayer.style.transform = `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / committedScale})`;
worldLayer.style.willChange = container.dataset.canvasViewportInteracting === "true" ? "transform" : "";
}
- container.style.setProperty("--canvas-live-scale", String(viewport.k));
- // 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。
- container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05)));
+ // 交互期不要修改根容器上的继承变量。它们会让整棵节点 DOM 子树失效并
+ // 触发样式重算;交互结束后由 CanvasViewport 的 committed render 一次性校正。
+ // 标题和选框在交互期暂时跟随世界层缩放,换取连续帧的合成路径。
+ if (container.dataset.canvasViewportInteracting !== "true") {
+ container.style.setProperty("--canvas-live-scale", String(viewport.k));
+ container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05)));
+ }
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {
diff --git a/web/src/lib/canvas/canvas-performance-mode.ts b/web/src/lib/canvas/canvas-performance-mode.ts
deleted file mode 100644
index 9e395ea15..000000000
--- a/web/src/lib/canvas/canvas-performance-mode.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData } from "@/types/canvas";
-
-const STORAGE_KEY = "canvas-media-performance-mode";
-
-// 屏外预加载的软预算;屏内节点不可因预算而消失,媒体仍由节点按需加载。
-export const CANVAS_MAX_RENDERED_NODES = 720;
-export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
-
-export function readCanvasMediaPerformanceMode(): CanvasMediaPerformanceMode {
- try {
- const stored = window.localStorage.getItem(STORAGE_KEY);
- return stored === "quality" || stored === "performance" ? stored : "auto";
- } catch {
- return "auto";
- }
-}
-
-export function persistCanvasMediaPerformanceMode(mode: CanvasMediaPerformanceMode) {
- try {
- window.localStorage.setItem(STORAGE_KEY, mode);
- } catch {
- // 浏览器禁用本地存储时保留当前会话内的选择。
- }
-}
-
-export function shouldReduceCanvasMediaEffects(mode: CanvasMediaPerformanceMode, nodes: CanvasNodeData[]) {
- if (mode === "performance") return true;
- if (mode === "quality") return false;
- const mediaCount = nodes.filter((node) => node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio).length;
- return nodes.length >= 80 || mediaCount >= 32;
-}
-
-export function canvasNodeRenderPadding(reduceMediaEffects: boolean, previouslyRendered: boolean) {
- if (previouslyRendered) return reduceMediaEffects ? 640 : 384;
- return reduceMediaEffects ? 128 : 192;
-}
-
-export function canvasNodeRenderBudget(scale: number) {
- if (scale < 0.14) return 280;
- if (scale < 0.28) return 420;
- return CANVAS_MAX_RENDERED_NODES;
-}
-
-export function resolveActiveCanvasMediaNodeId(selectedNodeIds: ReadonlySet, nodeById: ReadonlyMap) {
- if (selectedNodeIds.size !== 1) return null;
- const nodeId = selectedNodeIds.values().next().value;
- if (!nodeId) return null;
- const type = nodeById.get(nodeId)?.type;
- return type === CanvasNodeType.Video || type === CanvasNodeType.Audio ? nodeId : null;
-}
diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts
index b18fb54a3..40391ab35 100644
--- a/web/src/lib/canvas/canvas-project-domain.ts
+++ b/web/src/lib/canvas/canvas-project-domain.ts
@@ -120,6 +120,13 @@ export function storyboardRowsOutputContract(requirement: string) {
].join("\n");
}
+// 旧版本会把项目画风执行规范追加到分镜剧情末尾;分镜规划只保留用户原始剧情。
+export function stripProjectStyleExecutionBlocks(prompt: string): string {
+ const marker = "【项目画风执行规范】";
+ const markerIndex = prompt.indexOf(marker);
+ return (markerIndex >= 0 ? prompt.slice(0, markerIndex) : prompt).trim();
+}
+
// 分镜文本任务实际发出的 prompt:技能上下文只描述工作流、不约束输出结构,画布节点和章节分镜又都没有
// metadata.promptTemplateOperation(后端只在有该字段时编译服务端模板),所以契约必须由调用方显式拼上,
// 否则模型按自然语言习惯返回 Markdown 表格或直接续写正文,解析阶段拿不到 rows。
diff --git a/web/src/lib/canvas/canvas-render-budget.ts b/web/src/lib/canvas/canvas-render-budget.ts
new file mode 100644
index 000000000..66198d4d8
--- /dev/null
+++ b/web/src/lib/canvas/canvas-render-budget.ts
@@ -0,0 +1,12 @@
+export const CANVAS_MAX_RENDERED_NODES = 720;
+export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
+
+export function canvasNodeRenderPadding(previouslyRendered: boolean) {
+ return previouslyRendered ? 384 : 192;
+}
+
+export function canvasNodeRenderBudget(scale: number) {
+ if (scale < 0.14) return 280;
+ if (scale < 0.28) return 420;
+ return CANVAS_MAX_RENDERED_NODES;
+}
diff --git a/web/src/lib/canvas/canvas-storage-revision.ts b/web/src/lib/canvas/canvas-storage-revision.ts
index 832f6f690..f4c846d0e 100644
--- a/web/src/lib/canvas/canvas-storage-revision.ts
+++ b/web/src/lib/canvas/canvas-storage-revision.ts
@@ -225,6 +225,12 @@ function mergeEntities(input: {
const durable = durableById.get(id);
if (base && !local) {
+ // A concurrent durable edit must not be erased by a stale local delete.
+ // Keep the durable branch and surface a conflict for the caller.
+ if (durable && !deepEqual(base, durable)) {
+ input.conflicts.push(input.conflict(id));
+ continue;
+ }
remove(id);
input.tombstones[id] = input.nextRevision;
continue;
@@ -233,7 +239,12 @@ function mergeEntities(input: {
if (!durable) {
if (base || (input.tombstones[id] ?? 0) > input.baseRevision) {
+ // A local recreation is still user work. Record the concurrent
+ // deletion, but keep the node and clear the stale tombstone so a
+ // later rebase cannot suppress the same ID forever.
input.conflicts.push(input.conflict(id));
+ delete input.tombstones[id];
+ set(local);
continue;
}
delete input.tombstones[id];
@@ -309,12 +320,7 @@ function mergeSessions(input: {
}
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
- if (local.revision !== durable.revision) {
- // Never attach another tab's newer server revision to this tab's stale edits.
- // Preserve the whole edited branch; the server will reject its stale revision.
- if (local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
- return local;
- }
+ if (local.revision !== durable.revision && local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
const common = {
projectId: local.id,
tombstones: document.tombstones,
@@ -326,7 +332,7 @@ function mergeProject(base: CanvasProject | undefined, local: CanvasProject, dur
(base || {}) as unknown as Record,
local as unknown as Record,
durable as unknown as Record,
- new Set(["nodes", "connections", "chatSessions"]),
+ new Set(["nodes", "connections", "chatSessions", "revision", "remoteContentHash", "viewport"]),
concurrentConflictRecorder(conflicts, { kind: "project", id: local.id }),
) as unknown as CanvasProject;
merged.nodes = mergeEntities({
@@ -367,6 +373,9 @@ function mergeProject(base: CanvasProject | undefined, local: CanvasProject, dur
local: local.chatSessions,
durable: durable.chatSessions,
});
+ merged.revision = local.revision ?? durable.revision;
+ merged.viewport = local.viewport;
+ merged.remoteContentHash = undefined;
return merged;
}
diff --git a/web/src/lib/canvas/canvas-storyboard-context.ts b/web/src/lib/canvas/canvas-storyboard-context.ts
index 8b12e5f3e..e58a581c0 100644
--- a/web/src/lib/canvas/canvas-storyboard-context.ts
+++ b/web/src/lib/canvas/canvas-storyboard-context.ts
@@ -1,12 +1,6 @@
import type { CanvasNodeData } from "@/types/canvas";
export type StoryboardGenerationContext = {
- projectStyle: {
- presetId: string;
- title: string;
- prompt: string;
- profileJson?: string;
- };
characters: Array<{
assetId: string;
versionId: string;
@@ -15,13 +9,8 @@ export type StoryboardGenerationContext = {
}>;
};
-// 分镜的两条入口共用同一强校验,避免画风或角色版本在某条旁路里被遗漏。
+// 分镜的两条入口共用角色资产校验,避免角色版本在某条旁路里被遗漏。
export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): StoryboardGenerationContext {
- const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
- const stylePrompt = String(styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim();
- const stylePresetId = String(styleNode?.metadata?.stylePresetId || "").trim();
- if (!styleNode || !stylePrompt || !stylePresetId) throw new Error("请先设置项目画风,再生成分镜");
-
// `workflowKind=character` is also used by standalone character-design
// image workflows. Only nodes linked to a project character asset are
// storyboard character cards and therefore participate in this check.
@@ -30,12 +19,6 @@ export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): Sto
if (invalidCharacter) throw new Error(`角色卡“${invalidCharacter.metadata?.characterName || invalidCharacter.title || "未命名角色"}”版本未同步,请刷新角色资产后再生成分镜`);
return {
- projectStyle: {
- presetId: stylePresetId,
- title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title,
- prompt: stylePrompt,
- profileJson: styleNode.metadata?.styleProfileJson,
- },
characters: characterNodes.map((node) => ({
assetId: node.metadata!.characterAssetId!.trim(),
versionId: node.metadata!.characterVersionId!.trim(),
diff --git a/web/src/lib/canvas/previs/previs-diagnostics.ts b/web/src/lib/canvas/previs/previs-diagnostics.ts
index 0e0aa6a17..a170c614f 100644
--- a/web/src/lib/canvas/previs/previs-diagnostics.ts
+++ b/web/src/lib/canvas/previs/previs-diagnostics.ts
@@ -1,15 +1,8 @@
/**
* 预演台诊断领域:稳定白名单 code + 安全结构化字段。
- *
- * 调用边界的硬约束(安全边界,不是风格偏好):
- * - 绝不接收 Error / stack / componentStack / URL / Cookie / Authorization / token / 密钥;
- * - 绝不接收 scene 名称、素材正文、prompt 等业务文本;
- * - message 只能来自本模块的固定常量表,调用方不能自带文案;
- * - 字段只允许「白名单枚举 | 布尔 | 有限数值 | 通过 safe-id 校验的 id」;
- * - 未知 code 与未知字段一律丢弃,不透传。
+ * 调用边界只接收受限枚举、布尔、有限数值和 safe-id,不透传业务正文、URL、凭证或错误栈。
*/
-/** 故障与恢复代码白名单。新增代码必须同时补 message 与测试。 */
export const PREVIS_DIAGNOSTIC_CODES = [
"PREVIS_VIEWPORT_RENDER_FAILED",
"PREVIS_VIEWPORT_CONTEXT_LOST",
@@ -18,17 +11,12 @@ export const PREVIS_DIAGNOSTIC_CODES = [
"PREVIS_MODEL_LOAD_RETRY",
"PREVIS_MODEL_ADOPT_FAILED",
"PREVIS_SAVE_FLUSH_FAILED",
- "PREVIS_SAVE_RETRY_FAILED",
- "PREVIS_SAVE_RETRY_RECOVERED",
- "PREVIS_SAVE_DRAFT_UNAVAILABLE",
"PREVIS_CLOSE_BLOCKED",
] as const;
export type PrevisDiagnosticCode = (typeof PREVIS_DIAGNOSTIC_CODES)[number];
-
const CODE_SET: ReadonlySet = new Set(PREVIS_DIAGNOSTIC_CODES);
-/** 固定文案:调用方不得传入任意字符串,避免业务正文经 message 泄漏。 */
const MESSAGES: Record = {
PREVIS_VIEWPORT_RENDER_FAILED: "预演台 3D 视口渲染失败",
PREVIS_VIEWPORT_CONTEXT_LOST: "预演台 WebGL 上下文丢失",
@@ -37,9 +25,6 @@ const MESSAGES: Record = {
PREVIS_MODEL_LOAD_RETRY: "预演台 3D 模型重试加载",
PREVIS_MODEL_ADOPT_FAILED: "预演台 3D 模型采纳失败",
PREVIS_SAVE_FLUSH_FAILED: "预演台场景保存失败",
- PREVIS_SAVE_RETRY_FAILED: "预演台场景重试保存仍失败",
- PREVIS_SAVE_RETRY_RECOVERED: "预演台场景重试保存成功",
- PREVIS_SAVE_DRAFT_UNAVAILABLE: "预演台本地草稿不可用",
PREVIS_CLOSE_BLOCKED: "预演台关闭被阻止",
};
@@ -51,40 +36,24 @@ const LEVELS: Record = {
PREVIS_MODEL_LOAD_RETRY: "info",
PREVIS_MODEL_ADOPT_FAILED: "error",
PREVIS_SAVE_FLUSH_FAILED: "error",
- PREVIS_SAVE_RETRY_FAILED: "error",
- PREVIS_SAVE_RETRY_RECOVERED: "info",
- PREVIS_SAVE_DRAFT_UNAVAILABLE: "error",
PREVIS_CLOSE_BLOCKED: "warning",
};
-/** 对象种类枚举:只表达形态,绝不表达名称或素材来源。 */
export const PREVIS_OBJECT_KINDS = ["actor", "model", "billboard", "primitive", "unknown"] as const;
export type PrevisDiagnosticObjectKind = (typeof PREVIS_OBJECT_KINDS)[number];
const OBJECT_KIND_SET: ReadonlySet = new Set(PREVIS_OBJECT_KINDS);
-/** 保存决策枚举:与 coordinator 的关闭决策同构,但不携带任何场景内容。 */
-export const PREVIS_SAVE_OUTCOMES = ["close", "offer-draft-exit", "stay"] as const;
+export const PREVIS_SAVE_OUTCOMES = ["close", "stay"] as const;
export type PrevisDiagnosticSaveOutcome = (typeof PREVIS_SAVE_OUTCOMES)[number];
const SAVE_OUTCOME_SET: ReadonlySet = new Set(PREVIS_SAVE_OUTCOMES);
-/**
- * 允许的结构化字段。全部可选,且每个字段都有独立的校验规则。
- * 这里故意不提供任何自由文本字段。
- */
export type PrevisDiagnosticFields = {
- /** 对象 id:safe-id 校验;不通过则丢弃。 */
objectId?: string;
- /** 场景 id:safe-id 校验。绝不接收 scene 名称。 */
sceneId?: string;
objectKind?: PrevisDiagnosticObjectKind;
saveOutcome?: PrevisDiagnosticSaveOutcome;
- /** 加载/保存重试次数。 */
attempt?: number;
- /** 修订号。 */
revision?: number;
- /** 本地草稿是否可用。 */
- draftStored?: boolean;
- /** 是否由用户显式触发(区分自动重试与手动重试)。 */
userInitiated?: boolean;
};
@@ -101,7 +70,6 @@ export function isPrevisDiagnosticCode(value: unknown): value is PrevisDiagnosti
return typeof value === "string" && CODE_SET.has(value);
}
-/** id 白名单校验:任何含 URL、query、路径分隔、空格、凭证形态的输入都不合法。 */
function safePrevisId(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
@@ -115,48 +83,27 @@ function boundedInteger(value: unknown, min: number, max: number): number | unde
return rounded;
}
-/**
- * 纯投影:把任意输入收敛成安全事件。未知 code 返回 null,调用方据此丢弃。
- * 未知字段、非法 id、越界数值、非布尔值一律不出现在输出里。
- */
export function projectPrevisDiagnostic(code: unknown, fields: unknown): PrevisDiagnosticEvent | null {
if (!isPrevisDiagnosticCode(code)) return null;
-
const source: Record = fields && typeof fields === "object" && !Array.isArray(fields) ? (fields as Record) : {};
const safe: PrevisDiagnosticFields = {};
const objectId = safePrevisId(source.objectId);
if (objectId) safe.objectId = objectId;
-
const sceneId = safePrevisId(source.sceneId);
if (sceneId) safe.sceneId = sceneId;
-
- if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) {
- safe.objectKind = source.objectKind as PrevisDiagnosticObjectKind;
- }
- if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) {
- safe.saveOutcome = source.saveOutcome as PrevisDiagnosticSaveOutcome;
- }
+ if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) safe.objectKind = source.objectKind as PrevisDiagnosticObjectKind;
+ if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) safe.saveOutcome = source.saveOutcome as PrevisDiagnosticSaveOutcome;
const attempt = boundedInteger(source.attempt, 0, 999);
if (attempt !== undefined) safe.attempt = attempt;
-
const revision = boundedInteger(source.revision, 0, Number.MAX_SAFE_INTEGER);
if (revision !== undefined) safe.revision = revision;
-
- if (typeof source.draftStored === "boolean") safe.draftStored = source.draftStored;
if (typeof source.userInitiated === "boolean") safe.userInitiated = source.userInitiated;
return { code, level: LEVELS[code], message: MESSAGES[code], fields: safe };
}
-/**
- * 把安全字段压成 code 后缀,便于在统一诊断流里检索而无需自由文本。
- *
- * scene/object 走 safe-id 投影后的值(非法 id 在投影阶段已被丢弃,不会到这里),
- * 因此后缀里只可能出现受限字符集。完整签名同时用于去重:
- * 不同 objectId / sceneId 必须是不同事件,不能在去重窗口内互相吞掉。
- */
export function formatPrevisDiagnosticCode(event: PrevisDiagnosticEvent): string {
const parts: string[] = [event.code];
if (event.fields.sceneId) parts.push("scene=" + event.fields.sceneId);
@@ -165,12 +112,10 @@ export function formatPrevisDiagnosticCode(event: PrevisDiagnosticEvent): string
if (event.fields.saveOutcome) parts.push("outcome=" + event.fields.saveOutcome);
if (event.fields.attempt !== undefined) parts.push("attempt=" + event.fields.attempt);
if (event.fields.revision !== undefined) parts.push("revision=" + event.fields.revision);
- if (event.fields.draftStored !== undefined) parts.push("draft=" + (event.fields.draftStored ? "1" : "0"));
if (event.fields.userInitiated !== undefined) parts.push("user=" + (event.fields.userInitiated ? "1" : "0"));
return parts.join(" ");
}
-/** 从 PrevisObject 的形态推导枚举,不读取 name / url / storageKey。 */
export function previsDiagnosticObjectKind(object: { kind?: string; primitive?: string } | null | undefined): PrevisDiagnosticObjectKind {
if (!object) return "unknown";
if (object.kind === "actor") return "actor";
diff --git a/web/src/lib/canvas/previs/previs-save-wiring.ts b/web/src/lib/canvas/previs/previs-save-wiring.ts
index a595a1342..b81b83374 100644
--- a/web/src/lib/canvas/previs/previs-save-wiring.ts
+++ b/web/src/lib/canvas/previs/previs-save-wiring.ts
@@ -1,122 +1,42 @@
-import type { PrevisScene } from "@/types/previs";
-import type { PrevisCloseDecision, PrevisDraftEnvelope, PrevisSaveSnapshot, StorageLike } from "@/lib/canvas/previs/previs-save";
+import type { PrevisCloseDecision, PrevisSaveSnapshot } from "@/lib/canvas/previs/previs-save";
-/**
- * UI 只需要保存进度,不需要场景本体。
- * 去掉 scene 后 React state 不必再为「还没有 coordinator」编造一个假场景。
- */
-export type PrevisSaveProgress = Omit;
+export type PrevisSaveProgress = PrevisSaveSnapshot;
export const idlePrevisSaveProgress: PrevisSaveProgress = {
revision: 0,
confirmedRevision: 0,
status: "saved",
- draftStored: false,
};
-/** 从完整快照投影出进度:订阅回调必须走它,避免只更新 status 而漏掉确认信息。 */
export function previsSaveProgress(snapshot: PrevisSaveSnapshot): PrevisSaveProgress {
return {
revision: snapshot.revision,
confirmedRevision: snapshot.confirmedRevision,
status: snapshot.status,
- draftStored: snapshot.draftStored,
};
}
-/** 头部状态展示:文案与语气必须由状态唯一决定,组件不得自己拼措辞。 */
export type PrevisSaveIndicator = {
label: string;
tone: "idle" | "pending" | "danger";
- /** 未确认或错误时提供重试;已保存态没有可重试的东西。 */
- retryable: boolean;
busy: boolean;
};
export function describePrevisSaveStatus(progress: PrevisSaveProgress): PrevisSaveIndicator {
const unconfirmed = progress.confirmedRevision < progress.revision;
-
- if (progress.status === "error") {
- return {
- label: progress.draftStored ? "保存失败 · 本地草稿已保留" : "保存失败 · 本地草稿也未写入",
- tone: "danger",
- retryable: true,
- busy: false,
- };
- }
- if (progress.status === "saving") {
- return { label: "正在保存…", tone: "pending", retryable: false, busy: true };
- }
- if (progress.status === "dirty" || unconfirmed) {
- return { label: "有未保存修改", tone: "pending", retryable: true, busy: false };
- }
- return { label: "已保存", tone: "idle", retryable: false, busy: false };
+ if (progress.status === "error") return { label: "保存暂时未完成 · 未确认操作将在后台继续处理", tone: "danger", busy: false };
+ if (progress.status === "saving") return { label: "正在保存…", tone: "pending", busy: true };
+ if (progress.status === "dirty" || unconfirmed) return { label: "有未确认操作,后台处理中", tone: "pending", busy: false };
+ return { label: "已保存", tone: "idle", busy: false };
}
-/**
- * 是否提示恢复:候选必须比权威场景更新才值得打断用户。
- * scene.updatedAt 是 canonical 保存时间,candidate.baseUpdatedAt 是草稿写入时的基线;
- * 基线落后于权威说明远端已保存过更新的内容,这份草稿是陈旧残留。
- */
-export function shouldOfferPrevisDraftRecovery(input: { candidate: PrevisDraftEnvelope | null; authoritativeScene: PrevisScene | null }): boolean {
- const { candidate, authoritativeScene } = input;
- if (!candidate || !authoritativeScene) return false;
- if (candidate.sceneId !== authoritativeScene.id) return false;
- if (candidate.baseUpdatedAt !== authoritativeScene.updatedAt) return false;
- return candidate.scene.updatedAt > authoritativeScene.updatedAt;
-}
-
-/** 关闭决策的用户可见后果,组件按此分支决定退出/二次确认/留下。 */
-export type PrevisCloseOutcome = { kind: "close" } | { kind: "confirm-draft-exit"; message: string } | { kind: "blocked"; message: string };
+export type PrevisCloseOutcome = { kind: "close" } | { kind: "blocked"; message: string };
export function resolvePrevisCloseOutcome(decision: PrevisCloseDecision): PrevisCloseOutcome {
if (decision === "close") return { kind: "close" };
- if (decision === "offer-draft-exit") {
- return {
- kind: "confirm-draft-exit",
- message: "远端保存失败,但本地草稿已安全保存。下次打开这个镜头可以恢复,确认离开吗?",
- };
- }
- return { kind: "blocked", message: "保存失败且本地草稿未写入,已留在预演台,请重试保存。" };
+ return { kind: "blocked", message: "保存暂时未完成,编辑内容已保留,未确认操作会在后台自动处理。" };
}
-/**
- * beforeunload 是否要触发浏览器保护:只有「改动未确认」或「错误态且没有安全草稿」才拦。
- * 注意这里只能同步表达意图,异步 flush 无法阻塞卸载。
- */
export function shouldBlockPrevisUnload(progress: PrevisSaveProgress): boolean {
- if (progress.confirmedRevision < progress.revision) return true;
- return progress.status === "error" && !progress.draftStored;
-}
-
-/**
- * localStorage 不可用(隐私模式、被禁用、SSR)时的显式实现:
- * 读返回 null,写和删都抛,让 coordinator 如实得到 draftStored=false。
- * 绝不能降级成进程内 Map —— 那会让弹窗谎称「下次可恢复」。
- */
-export const unavailablePrevisDraftStorage: StorageLike = {
- getItem: () => null,
- setItem: () => {
- throw new Error("localStorage unavailable");
- },
- removeItem: () => {
- throw new Error("localStorage unavailable");
- },
-};
-
-/** 纯 factory:注入 getter 便于测试不可用分支,不引入额外依赖。 */
-export function resolvePrevisDraftStorage(readStorage: () => Storage | null | undefined): StorageLike {
- let local: Storage | null | undefined;
- try {
- local = readStorage();
- } catch {
- return unavailablePrevisDraftStorage;
- }
- if (!local) return unavailablePrevisDraftStorage;
-
- return {
- getItem: (key) => local.getItem(key),
- setItem: (key, value) => local.setItem(key, value),
- removeItem: (key) => local.removeItem(key),
- };
+ return progress.confirmedRevision < progress.revision || progress.status === "error";
}
diff --git a/web/src/lib/canvas/previs/previs-save.ts b/web/src/lib/canvas/previs/previs-save.ts
index c50c015ca..1601e81d9 100644
--- a/web/src/lib/canvas/previs/previs-save.ts
+++ b/web/src/lib/canvas/previs/previs-save.ts
@@ -1,17 +1,7 @@
-import { scopedStorageKey } from "@/lib/user-scope";
import type { PrevisScene } from "@/types/previs";
export type PrevisSaveStatus = "dirty" | "saving" | "saved" | "error";
-export type PrevisCloseDecision = "close" | "offer-draft-exit" | "stay";
-
-export type PrevisDraftEnvelope = {
- schemaVersion: 1;
- sceneId: string;
- baseUpdatedAt: string;
- scene: PrevisScene;
- revision: number;
- savedAt: number;
-};
+export type PrevisCloseDecision = "close" | "stay";
export type PrevisFlushRequest = {
scene: PrevisScene;
@@ -19,32 +9,19 @@ export type PrevisFlushRequest = {
baseUpdatedAt: string;
};
-export type StorageLike = {
- getItem: (key: string) => string | null;
- setItem: (key: string, value: string) => void;
- removeItem: (key: string) => void;
-};
-
export type FlushFunction = (request: Readonly) => Promise;
-
export type ScheduleFunction = (callback: () => void, delay: number) => unknown;
-
export type CancelScheduleFunction = (handle: unknown) => void;
export type PrevisSaveSnapshot = {
- scene: PrevisScene;
revision: number;
confirmedRevision: number;
status: PrevisSaveStatus;
- draftStored: boolean;
};
export type PrevisSaveCoordinatorOptions = {
initialScene: PrevisScene;
- scope: string;
- storage: StorageLike;
flush: FlushFunction;
- now?: () => number;
debounceMs?: number;
schedule?: ScheduleFunction;
cancelSchedule?: CancelScheduleFunction;
@@ -54,94 +31,25 @@ export interface PrevisSaveCoordinator {
getSnapshot: () => PrevisSaveSnapshot;
edit: (scene: PrevisScene) => boolean;
flushLatest: () => Promise;
- retry: () => Promise;
- restoreCandidate: () => PrevisDraftEnvelope | null;
- discardDraft: () => boolean;
- explicitRestore: (candidate: unknown) => boolean;
prepareClose: () => Promise;
handlePageHide: () => Promise;
onStatusChange: (handler: (status: PrevisSaveStatus) => void) => () => void;
dispose: () => void;
}
-const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value);
-
const cloneScene = (scene: PrevisScene): PrevisScene => structuredClone(scene);
-const isPrevisScene = (value: unknown, expectedSceneId: string): value is PrevisScene => {
- if (!isRecord(value)) return false;
-
- return (
- value.id === expectedSceneId &&
- value.version === 1 &&
- typeof value.title === "string" &&
- typeof value.background === "string" &&
- typeof value.environmentIntensity === "number" &&
- Number.isFinite(value.environmentIntensity) &&
- typeof value.gridVisible === "boolean" &&
- Array.isArray(value.objects) &&
- Array.isArray(value.cameras) &&
- Array.isArray(value.lights) &&
- Array.isArray(value.shots) &&
- typeof value.activeShotId === "string" &&
- typeof value.createdAt === "string" &&
- typeof value.updatedAt === "string"
- );
-};
-
-const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): PrevisDraftEnvelope | null => {
- if (!isRecord(candidate)) return null;
-
- if (
- candidate.schemaVersion !== 1 ||
- candidate.sceneId !== expectedSceneId ||
- typeof candidate.baseUpdatedAt !== "string" ||
- typeof candidate.revision !== "number" ||
- !Number.isSafeInteger(candidate.revision) ||
- candidate.revision < 0 ||
- typeof candidate.savedAt !== "number" ||
- !Number.isFinite(candidate.savedAt) ||
- candidate.savedAt < 0 ||
- !isPrevisScene(candidate.scene, expectedSceneId)
- ) {
- return null;
- }
-
- return {
- schemaVersion: 1,
- sceneId: expectedSceneId,
- baseUpdatedAt: candidate.baseUpdatedAt,
- scene: cloneScene(candidate.scene),
- revision: candidate.revision,
- savedAt: candidate.savedAt,
- };
-};
-
-const parseDraftEnvelope = (raw: string, expectedSceneId: string): PrevisDraftEnvelope | null => {
- try {
- const candidate: unknown = JSON.parse(raw);
- return validateDraftEnvelope(candidate, expectedSceneId);
- } catch {
- return null;
- }
-};
-
export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOptions): PrevisSaveCoordinator {
- const { initialScene, scope, storage, flush, now = Date.now, debounceMs = 300 } = options;
-
+ const { initialScene, flush, debounceMs = 300 } = options;
const schedule: ScheduleFunction = options.schedule ?? ((callback, delay) => globalThis.setTimeout(callback, delay));
const cancelSchedule: CancelScheduleFunction = options.cancelSchedule ?? ((handle) => globalThis.clearTimeout(handle as ReturnType));
const fixedSceneId = initialScene.id;
- const storageKey = scopedStorageKey("previs-scene-draft:" + fixedSceneId, scope);
-
let currentScene = cloneScene(initialScene);
let currentRevision = 0;
let confirmedRevision = 0;
let baseUpdatedAt = initialScene.updatedAt;
let status: PrevisSaveStatus = "saved";
- let localDraft: PrevisDraftEnvelope | null = null;
- let draftStored = false;
let debounceHandle: unknown | null = null;
let inFlightFlush: Promise | null = null;
let closePromise: Promise | null = null;
@@ -163,11 +71,9 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
};
const getSnapshot = (): PrevisSaveSnapshot => ({
- scene: cloneScene(currentScene),
revision: currentRevision,
confirmedRevision,
status,
- draftStored,
});
const cancelDebounce = () => {
@@ -176,28 +82,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
debounceHandle = null;
};
- const storeCurrentDraft = () => {
- const envelope: PrevisDraftEnvelope = {
- schemaVersion: 1,
- sceneId: fixedSceneId,
- baseUpdatedAt,
- scene: cloneScene(currentScene),
- revision: currentRevision,
- savedAt: now(),
- };
-
- localDraft = envelope;
-
- try {
- storage.setItem(storageKey, JSON.stringify(envelope));
- draftStored = true;
- setStatus("dirty");
- } catch {
- draftStored = false;
- setStatus("error");
- }
- };
-
const scheduleFlush = () => {
cancelDebounce();
debounceHandle = schedule(() => {
@@ -208,49 +92,19 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
const edit = (scene: PrevisScene): boolean => {
if (disposed || scene.id !== fixedSceneId) return false;
-
currentScene = cloneScene(scene);
currentRevision += 1;
- storeCurrentDraft();
+ setStatus("dirty");
scheduleFlush();
return true;
};
- const clearStoredDraft = (revision: number) => {
- if (localDraft?.revision !== revision) return;
-
- try {
- storage.removeItem(storageKey);
- localDraft = null;
- draftStored = false;
- } catch {
- draftStored = true;
- }
- };
-
- const refreshStoredDraftBase = () => {
- if (!localDraft || localDraft.revision <= confirmedRevision) return;
-
- localDraft = {
- ...localDraft,
- baseUpdatedAt,
- };
-
- try {
- storage.setItem(storageKey, JSON.stringify(localDraft));
- draftStored = true;
- } catch {
- draftStored = false;
- }
- };
-
const runDrain = async (): Promise => {
try {
while (!disposed && confirmedRevision < currentRevision) {
const targetRevision = currentRevision;
const targetScene = cloneScene(currentScene);
const targetBaseUpdatedAt = baseUpdatedAt;
-
setStatus("saving");
try {
@@ -265,17 +119,12 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
}
if (disposed) return false;
-
confirmedRevision = Math.max(confirmedRevision, targetRevision);
baseUpdatedAt = targetScene.updatedAt;
-
if (confirmedRevision === currentRevision) {
- clearStoredDraft(targetRevision);
setStatus("saved");
return true;
}
-
- refreshStoredDraftBase();
}
if (disposed) return false;
@@ -289,7 +138,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
function flushLatest(): Promise {
cancelDebounce();
-
if (disposed) return Promise.resolve(false);
if (inFlightFlush) return inFlightFlush;
if (confirmedRevision === currentRevision) {
@@ -299,7 +147,6 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
const pending = runDrain();
inFlightFlush = pending;
-
void pending.then(
() => {
if (inFlightFlush === pending) inFlightFlush = null;
@@ -308,74 +155,14 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
if (inFlightFlush === pending) inFlightFlush = null;
},
);
-
return pending;
}
- const retry = (): Promise => flushLatest();
-
- const restoreCandidate = (): PrevisDraftEnvelope | null => {
- try {
- const raw = storage.getItem(storageKey);
- if (raw === null) return null;
- return parseDraftEnvelope(raw, fixedSceneId);
- } catch {
- return null;
- }
- };
-
- /**
- * 显式放弃本地草稿:真正删除该 scoped key,避免同一旧草稿每次重开都提示。
- * 删除失败返回 false,调用方必须告知用户草稿仍在,不能假装已放弃。
- */
- const discardDraft = (): boolean => {
- try {
- storage.removeItem(storageKey);
- } catch {
- return false;
- }
-
- localDraft = null;
- draftStored = false;
- return true;
- };
-
- const explicitRestore = (candidate: unknown): boolean => {
- if (disposed) return false;
-
- const envelope = validateDraftEnvelope(candidate, fixedSceneId);
- if (!envelope || envelope.revision <= currentRevision) return false;
-
- cancelDebounce();
- currentScene = cloneScene(envelope.scene);
- currentRevision = envelope.revision;
- baseUpdatedAt = envelope.baseUpdatedAt;
- localDraft = envelope;
-
- try {
- storage.setItem(storageKey, JSON.stringify(envelope));
- draftStored = true;
- setStatus("dirty");
- } catch {
- draftStored = false;
- setStatus("error");
- }
-
- return true;
- };
-
const prepareClose = (): Promise => {
if (disposed) return Promise.resolve("stay");
- if (confirmedRevision === currentRevision) {
- return Promise.resolve("close");
- }
+ if (confirmedRevision === currentRevision) return Promise.resolve("close");
if (closePromise) return closePromise;
-
- const pending = flushLatest().then((success) => {
- if (success) return "close";
- return draftStored ? "offer-draft-exit" : "stay";
- });
-
+ const pending = flushLatest().then((success) => (success ? "close" : "stay"));
closePromise = pending;
void pending.then(
() => {
@@ -385,17 +172,14 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
if (closePromise === pending) closePromise = null;
},
);
-
return pending;
};
const handlePageHide = (): Promise => {
if (disposed) return Promise.resolve(false);
if (pageHidePromise) return pageHidePromise;
-
const pending = flushLatest();
pageHidePromise = pending;
-
void pending.then(
() => {
if (pageHidePromise === pending) pageHidePromise = null;
@@ -404,22 +188,17 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
if (pageHidePromise === pending) pageHidePromise = null;
},
);
-
return pending;
};
const onStatusChange = (handler: (status: PrevisSaveStatus) => void): (() => void) => {
if (disposed) return () => undefined;
-
subscribers.add(handler);
- return () => {
- subscribers.delete(handler);
- };
+ return () => subscribers.delete(handler);
};
const dispose = () => {
if (disposed) return;
-
disposed = true;
cancelDebounce();
subscribers.clear();
@@ -427,17 +206,5 @@ export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOption
pageHidePromise = null;
};
- return {
- getSnapshot,
- edit,
- flushLatest,
- retry,
- restoreCandidate,
- discardDraft,
- explicitRestore,
- prepareClose,
- handlePageHide,
- onStatusChange,
- dispose,
- };
+ return { getSnapshot, edit, flushLatest, prepareClose, handlePageHide, onStatusChange, dispose };
}
diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts
index a91fb73c0..fb50e7a2a 100644
--- a/web/src/lib/model-picker-groups.ts
+++ b/web/src/lib/model-picker-groups.ts
@@ -15,6 +15,7 @@ export { isDirectSystemModel } from "@/lib/model-selection";
export function modelChannelLabel(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
+ if (channel.scope === "system") return cost?.channelLabel?.trim() || "系统模型";
return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
}
@@ -39,7 +40,7 @@ export function groupModelsForPicker(config: AiConfig, options: string[]): Model
const key = JSON.stringify(["channel", channel.id]);
groups.set(key, {
key,
- label: channel.name || "未命名渠道",
+ label: channel.scope === "system" ? "系统模型" : channel.name || "未命名渠道",
icon: modelIcon(config, otherModels[0]),
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : "我的模型",
kind: "channel",
diff --git a/web/src/lib/workspace-route-modules.ts b/web/src/lib/workspace-route-modules.ts
index a30a4ff4f..1947113d8 100644
--- a/web/src/lib/workspace-route-modules.ts
+++ b/web/src/lib/workspace-route-modules.ts
@@ -19,6 +19,8 @@ export function preloadWorkspaceRoute(pathnameOrSlug: string) {
// 根路径就是创作页,预加载时仍映射到其内部模块名。
const segments = pathnameOrSlug.replace(/^\//, "").split("/").filter(Boolean);
const slug = segments[0] || "create";
+ // 具体画布项目由 /canvas/:id 自己加载;不要在认证恢复阶段额外预加载画布列表。
+ if (slug === "canvas" && segments.length > 1) return;
if (slug === "projects" && segments.length > 1) {
void workspaceRouteLoaders.projectDetail();
return;
diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx
index d0d6c9860..997fba222 100644
--- a/web/src/pages/admin/components/admin-shell.tsx
+++ b/web/src/pages/admin/components/admin-shell.tsx
@@ -5,8 +5,6 @@ import {
ArrowLeft,
BarChart3,
BellRing,
- ChevronLeft,
- ChevronRight,
ChevronDown,
CloudUpload,
Coins,
@@ -24,6 +22,8 @@ import {
Moon,
Palette,
Paintbrush,
+ PanelLeftClose,
+ PanelLeftOpen,
PlugZap,
RadioTower,
RefreshCw,
@@ -172,20 +172,27 @@ function AdminShellLayout() {
-
-
-
-
- {!collapsed ? 返回创作台 : null}
-
-
+
+
+
+
+
+ {!collapsed ? 返回创作台 : null}
+
+
+ {!collapsed ? (
+
+
+
+ ) : null}
+
+ {collapsed ? (
+
+
+
+ ) : null}
-
-
- {collapsed ? : }
-
-
name.toLowerCase().includes(query));
+}
+
+/** 全选:并入当前可见集合,保留关键字外已经勾选的模型。 */
+export function selectVisibleModels(current: string[], visible: string[]): string[] {
+ return Array.from(new Set([...current, ...visible]));
+}
+
+/** 取消选中:只移除当前可见集合里的勾选,关键字外的勾选保持不变。 */
+export function deselectVisibleModels(current: string[], visible: string[]): string[] {
+ const hidden = new Set(visible);
+ return current.filter((name) => !hidden.has(name));
+}
diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx
index e28af1f15..606a51fd0 100644
--- a/web/src/pages/admin/components/channel-model-manager.tsx
+++ b/web/src/pages/admin/components/channel-model-manager.tsx
@@ -14,6 +14,7 @@ import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, Admin
import { ChannelOrderDialog } from "./channel-order-dialog";
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
+import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection";
import { Select } from "@/components/ui/base/select";
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise }) {
@@ -29,6 +30,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false);
const [fetchPreviewModels, setFetchPreviewModels] = useState([]);
const [selectedFetchModels, setSelectedFetchModels] = useState([]);
+ const [fetchKeyword, setFetchKeyword] = useState("");
const [importing, setImporting] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [keyword, setKeyword] = useState("");
@@ -108,6 +110,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
setFetchPreviewOpen(false);
setFetchPreviewModels([]);
setSelectedFetchModels([]);
+ setFetchKeyword("");
};
const importSelectedModels = async () => {
@@ -245,8 +248,11 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
const existingFetchModelKeys = new Set(items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean).map(normalizeFetchModelKey)));
const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name)));
const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length;
- const allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name));
- const fetchModelOptions = fetchPreviewModels.map((name) => {
+ const fetchKeywordQuery = fetchKeyword.trim().toLowerCase();
+ const visibleFetchModels = filterImportModels(fetchPreviewModels, fetchKeyword);
+ const allVisibleFetchModelsSelected = visibleFetchModels.length > 0 && visibleFetchModels.every((name) => selectedFetchModels.includes(name));
+ const selectedVisibleFetchCount = visibleFetchModels.filter((name) => selectedFetchModels.includes(name)).length;
+ const fetchModelOptions = visibleFetchModels.map((name) => {
const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name));
return {
label: alreadyExists ? (
@@ -451,27 +457,53 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
>
上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。
+
}
+ value={fetchKeyword}
+ placeholder="搜索模型标识,例如 minimax、sd2.5"
+ aria-label="搜索上游模型"
+ disabled={importing}
+ onChange={(event) => setFetchKeyword(event.target.value)}
+ />
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
+ {fetchKeywordQuery ? `,当前匹配 ${visibleFetchModels.length} 个(其中 ${selectedVisibleFetchCount} 个已勾选)` : ""}
- setSelectedFetchModels(fetchPreviewModels)}>
- 全选
+ setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}
+ >
+ {fetchKeywordQuery ? `全选匹配的 ${visibleFetchModels.length} 个` : "全选"}
- setSelectedFetchModels([])}>
- 取消全选
+ setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}
+ >
+ {fetchKeywordQuery ? "取消选中匹配的" : "取消全选"}
-
setSelectedFetchModels(values as string[])}
- />
+ {visibleFetchModels.length ? (
+ setSelectedFetchModels(values as string[])}
+ />
+ ) : (
+
+ 没有匹配「{fetchKeyword.trim()}」的模型
+
+ )}
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}
diff --git a/web/src/pages/admin/theme/admin-chrome.css b/web/src/pages/admin/theme/admin-chrome.css
index 32859d33a..3295c3138 100644
--- a/web/src/pages/admin/theme/admin-chrome.css
+++ b/web/src/pages/admin/theme/admin-chrome.css
@@ -33,27 +33,35 @@
[data-admin-root] .admin-sidebar-identity {
display: flex;
- height: 56px;
+ min-height: 54px;
align-items: center;
- padding: 0 12px;
- border-bottom: 0;
+ padding: 10px 8px 0;
+}
+
+[data-admin-root] .admin-sidebar-identity > span {
+ display: block;
+ width: 100%;
}
[data-admin-root] .admin-sidebar-brand-button {
display: flex;
min-width: 0;
+ min-height: 38px;
width: 100%;
align-items: center;
+ justify-content: center;
gap: 10px;
- padding: 4px;
+ padding: 0 7px;
border: 0;
+ border-radius: 10px;
background: transparent;
color: inherit;
- text-align: left;
+ transition: background-color 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
-[data-admin-root] .admin-sidebar-brand-button.is-collapsed {
- justify-content: center;
+[data-admin-root] .admin-sidebar-brand-button:hover,
+[data-admin-root] .admin-sidebar-brand-button:focus-visible {
+ background: var(--admin-nav-hover);
}
[data-admin-root] .admin-sidebar-brand-mark {
@@ -123,20 +131,18 @@
}
[data-admin-root] .admin-nav-collapsed-separator {
- height: 1px;
- margin: 8px 10px;
- background: var(--admin-divider);
+ display: none;
}
[data-admin-root] .admin-nav-link {
position: relative;
display: flex;
- height: 32px;
+ min-height: 44px;
align-items: center;
gap: 10px;
- padding: 0 8px;
+ padding: 0 12px;
border: 0;
- border-radius: var(--admin-radius);
+ border-radius: 8px;
color: var(--admin-text-secondary);
font-size: 13px;
font-weight: 500;
@@ -149,9 +155,18 @@
[data-admin-root] .admin-nav-link.is-collapsed {
justify-content: center;
+ width: 100%;
+ min-width: 0;
+ margin-inline: 0;
padding: 0;
}
+[data-admin-root] .admin-nav-group-items > span,
+[data-admin-root] .admin-sidebar-footer-row > span {
+ display: block;
+ width: 100%;
+}
+
[data-admin-root] .admin-nav-link:hover {
background: var(--admin-nav-hover);
color: var(--admin-text);
@@ -172,49 +187,78 @@
}
[data-admin-root] .admin-nav-link svg {
- width: 15px;
- height: 15px;
+ width: 16px;
+ height: 16px;
flex: 0 0 auto;
color: currentColor;
stroke-width: 1.75;
}
+[data-admin-root] .admin-sidebar.is-collapsed .admin-nav-link svg {
+ width: 18px;
+ height: 18px;
+}
+
[data-admin-root] .admin-nav-link:focus-visible,
[data-admin-root] .admin-icon-button:focus-visible,
-[data-admin-root] .admin-sidebar-edge-toggle:focus-visible {
+[data-admin-root] .admin-sidebar-collapse-toggle:focus-visible {
outline: 2px solid var(--admin-focus);
outline-offset: 1px;
}
[data-admin-root] .admin-sidebar-footer {
- padding: 8px;
- border-top: 1px solid var(--admin-border);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 12px 12px;
+ border-top: 0;
background: var(--admin-sidebar);
}
-[data-admin-root] .admin-sidebar-edge-toggle {
- position: absolute;
- top: 50%;
- left: calc(var(--admin-sidebar-width) - 11px);
- z-index: 4;
- width: 22px;
- height: 36px;
+[data-admin-root] .admin-sidebar-footer.is-collapsed {
+ padding-inline: 8px;
+}
+
+[data-admin-root] .admin-sidebar-footer-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 4px;
+}
+
+[data-admin-root] .admin-sidebar-footer .admin-nav-link {
+ flex: 1 1 auto;
+}
+
+[data-admin-root] .admin-sidebar-collapse-toggle {
+ display: grid;
+ width: 40px;
+ height: 40px;
+ flex: 0 0 40px;
place-items: center;
- border: 1px solid var(--admin-border);
- border-radius: 999px;
- background: var(--admin-surface);
- color: var(--admin-text-muted);
- transform: translateY(-50%);
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ color: var(--admin-text-secondary);
}
-[data-admin-root] .admin-sidebar-edge-toggle.is-collapsed {
- left: calc(var(--admin-sidebar-collapsed) - 11px);
-}
-
-[data-admin-root] .admin-sidebar-edge-toggle:hover {
+[data-admin-root] .admin-sidebar-collapse-toggle:hover {
+ background: var(--admin-nav-hover);
color: var(--admin-text);
}
+[data-admin-root] .admin-sidebar-collapse-toggle svg {
+ width: 18px;
+ height: 18px;
+}
+
+[data-admin-root] .admin-sidebar-collapse-toggle.is-collapsed {
+ width: 48px;
+ height: 48px;
+ flex: 0 0 48px;
+}
+
[data-admin-root] .admin-page-root,
[data-admin-root] .admin-page-root > div {
height: 100%;
diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx
index db99956de..46de6979c 100644
--- a/web/src/pages/canvas/canvas-project-top-bar.tsx
+++ b/web/src/pages/canvas/canvas-project-top-bar.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
-import { Clapperboard, CloudDownload, CloudUpload, CopyPlus, Focus, FolderKanban, Gauge, History, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Settings, Share2, Trash2, Undo2, Upload } from "lucide-react";
+import { Clapperboard, CloudDownload, CopyPlus, Focus, FolderKanban, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Settings, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { WorkspaceCreditGiftMark } from "@/components/layout/workspace-credit-gift-mark";
@@ -13,13 +13,9 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
-import type { CanvasMediaPerformanceMode } from "@/types/canvas";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
type CanvasTopBarProps = {
- syncStatus?: ReactNode;
- versionsOpen: boolean;
- onToggleVersions: () => void;
title: string;
titleDraft: string;
isTitleEditing: boolean;
@@ -32,7 +28,6 @@ type CanvasTopBarProps = {
onCreateProject: () => void;
onDeleteProject: () => void;
onSave: () => void | Promise
;
- onForceSave: () => void;
onImportImage: () => void;
onImportLibTV: () => void;
onImportTapNow: () => void;
@@ -40,8 +35,7 @@ type CanvasTopBarProps = {
onRedo: () => void;
onShare: () => void;
shortcutRequestNonce: number;
- mediaPerformanceMode: CanvasMediaPerformanceMode;
- onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
+ syncIcon?: ReactNode;
onOpenSearch: () => void;
projectContext?: CanvasContextSummary & { projectId: string; projectName: string };
onEnterFocusMode: () => void;
@@ -49,9 +43,6 @@ type CanvasTopBarProps = {
};
export function CanvasTopBar({
- syncStatus,
- versionsOpen,
- onToggleVersions,
title,
titleDraft,
isTitleEditing,
@@ -64,7 +55,6 @@ export function CanvasTopBar({
onCreateProject,
onDeleteProject,
onSave,
- onForceSave,
onImportImage,
onImportLibTV,
onImportTapNow,
@@ -72,8 +62,7 @@ export function CanvasTopBar({
onRedo,
onShare,
shortcutRequestNonce,
- mediaPerformanceMode,
- onMediaPerformanceModeChange,
+ syncIcon,
onOpenSearch,
projectContext,
onEnterFocusMode,
@@ -119,20 +108,9 @@ export function CanvasTopBar({
{ key: "new", icon: , label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: , label: "删除当前画布", onClick: onDeleteProject },
{ key: "save", icon: , label: , onClick: () => void onSave() },
- { key: "force-save", icon: , label: "修复素材关联并保存", onClick: onForceSave },
{ type: "divider" },
{ key: "import", icon: , label: "导入素材", onClick: onImportImage },
{ key: "search", icon: , label: , onClick: onOpenSearch },
- {
- key: "performance",
- icon: ,
- label: "媒体性能",
- children: [
- { key: "performance-auto", label: "自动性能", onClick: () => onMediaPerformanceModeChange("auto") },
- { key: "performance-quality", label: "画质优先", onClick: () => onMediaPerformanceModeChange("quality") },
- { key: "performance-fast", label: "性能优先", onClick: () => onMediaPerformanceModeChange("performance") },
- ],
- },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: , label: , onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: , label: , onClick: onRedo },
@@ -194,7 +172,7 @@ export function CanvasTopBar({
) : null}
- {syncStatus}
+ {syncIcon}
@@ -224,23 +202,6 @@ export function CanvasTopBar({
-
- onMediaPerformanceModeChange(key as CanvasMediaPerformanceMode),
- items: [
- { key: "auto", label: "自动性能" },
- { key: "quality", label: "画质优先" },
- { key: "performance", label: "性能优先" },
- ],
- }}
- >
- } aria-label="媒体性能模式" />
-
-
{user && creditsEnabled ? (
) : null}
-
- }
- aria-label="版本记录"
- aria-pressed={versionsOpen}
- onClick={onToggleVersions}
- >
- 版本
-
-
} onClick={onShare} aria-label="分享画布" />
diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx
index a68186c4e..4894c4c92 100644
--- a/web/src/pages/canvas/canvas-project-world-layers.tsx
+++ b/web/src/pages/canvas/canvas-project-world-layers.tsx
@@ -44,7 +44,6 @@ type CanvasProjectWorldLayersProps = {
openingBatchIds: Set
;
batchMotionById: Map;
showImageInfo: boolean;
- reduceMediaEffects: boolean;
resourceReferenceByNodeId: Map;
mentionReferencesByNodeId: Map;
mediaEffectsDisabledNodeId?: string | null;
@@ -72,7 +71,6 @@ type CanvasProjectWorldLayersProps = {
onRetry: (node: CanvasNodeData) => void;
onReloadResource: (node: CanvasNodeData) => void;
onOpenTaskDetails: (node: CanvasNodeData) => void;
- onOpenVersions: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onReplaceMedia: (node: CanvasNodeData) => void;
onOpenTextEditor: (node: CanvasNodeData) => void;
@@ -176,7 +174,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)}
batchMotion={props.batchMotionById.get(node.id)}
showImageInfo={props.showImageInfo}
- reduceMediaEffects={props.reduceMediaEffects || props.mediaEffectsDisabledNodeId === node.id}
+ disableMediaEffects={props.mediaEffectsDisabledNodeId === node.id}
resourceLabel={props.resourceReferenceByNodeId.get(node.id)}
mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES}
renderNodeContent={props.renderCanvasNodeContent}
@@ -193,7 +191,6 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
onRetry={props.onRetry}
onReloadResource={props.onReloadResource}
onOpenTaskDetails={props.onOpenTaskDetails}
- onOpenVersions={props.onOpenVersions}
onViewImage={props.onViewImage}
onReplaceMedia={props.onReplaceMedia}
onOpenTextEditor={props.onOpenTextEditor}
diff --git a/web/src/pages/canvas/canvas-sync-status.css b/web/src/pages/canvas/canvas-sync-status.css
index ecedc0fcc..052b8f125 100644
--- a/web/src/pages/canvas/canvas-sync-status.css
+++ b/web/src/pages/canvas/canvas-sync-status.css
@@ -13,7 +13,6 @@
.canvas-sync-status-trigger:hover,
.canvas-sync-status-trigger[aria-expanded="true"] {
background: var(--user-surface-hover, var(--accent));
- color: var(--user-ink, var(--foreground));
}
.canvas-sync-status-trigger--done { color: var(--status-success, #3f8f68); }
diff --git a/web/src/pages/canvas/canvas-sync-status.tsx b/web/src/pages/canvas/canvas-sync-status.tsx
index 316718fec..640d78824 100644
--- a/web/src/pages/canvas/canvas-sync-status.tsx
+++ b/web/src/pages/canvas/canvas-sync-status.tsx
@@ -1,227 +1,37 @@
-import { App, Button, Popover } from "antd";
-import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw, UploadCloud } from "lucide-react";
-import { useState } from "react";
-import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
+import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
+import { useEffect, useState } from "react";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
-import { overwriteRemoteCanvasProject, retryRemoteUserDataSync } from "@/services/user-data-sync";
import "./canvas-sync-status.css";
-export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise; onOpenVersions?: () => void }) {
- const { message } = App.useApp();
+const ERROR_INDICATOR_DELAY_MS = 4_000;
+
+export function CanvasSyncStatus({ projectId }: { projectId: string }) {
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
- const [busy, setBusy] = useState(false);
- const [statusOpen, setStatusOpen] = useState(false);
const phase = progress?.phase;
- const conflict = phase === "conflict";
- const cloudError = phase === "error";
const localPhase = progress?.localPhase;
const localError = localPhase === "error";
- const localPending = localPhase === "pending";
- const retryScheduled = cloudError && progress?.message?.includes("自动重试");
- const saving = phase === "pending" || phase === "saving" || phase === "uploading";
- const reconciling = phase === "reconciling";
- const tone = conflict || cloudError || localError ? (conflict ? "conflict" : "error") : saving || reconciling || localPending ? "pending" : phase === "done" ? "done" : localPhase === "saved" ? "done" : "idle";
- const label = conflict
- ? "需要处理冲突"
- : cloudError
- ? "云端保存失败"
- : localError
- ? "本地保存失败"
- : reconciling
- ? "正在自动合并"
- : saving
- ? "正在同步云端"
- : localPending
- ? "正在保存本地"
- : phase === "done"
- ? "已保存到云端"
- : localPhase === "saved"
- ? "已保存在本地"
- : "尚未同步";
- const description = conflict
- ? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。"
- : cloudError
- ? progress?.message || "本地内容仍然保留,云端暂未确认。"
- : localError
- ? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。"
- : reconciling
- ? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
- : saving
- ? progress?.message || "本地内容已保留,正在同步到云端。"
- : localPending
- ? "正在把最近编辑写入浏览器本地存储。"
- : phase === "done"
- ? progress?.message || "当前画布与云端版本一致。"
- : localPhase === "saved"
- ? "当前编辑已保存到浏览器本地,登录后会继续同步到云端。"
- : "本地内容会先保存到浏览器,再尝试同步到云端。";
- const localStatus = localError ? progress?.localError || "保存失败" : localPending ? "正在保存" : localPhase === "saved" ? `已保存${progress?.localSavedAt ? ` · ${new Date(progress.localSavedAt).toLocaleTimeString()}` : ""}` : "尚未确认";
- const cloudStatus = conflict
- ? "存在冲突"
- : cloudError
- ? progress?.message || "保存失败"
- : saving || reconciling
- ? "正在同步"
- : phase === "done"
- ? `已保存${progress?.cloudRevision !== undefined ? ` · 版本 ${progress.cloudRevision}` : ""}`
- : "尚未同步";
+ const cloudError = phase === "error";
+ const errorKey = cloudError || localError ? `${projectId}:${cloudError}:${localError}` : "";
+ const [visibleErrorKey, setVisibleErrorKey] = useState("");
- const run = async (operation: () => Promise) => {
- setBusy(true);
- try {
- await operation();
- } catch (cause) {
- message.error(cause instanceof Error ? cause.message : "操作失败,请重试");
- throw cause;
- } finally {
- setBusy(false);
+ useEffect(() => {
+ if (!errorKey) {
+ setVisibleErrorKey("");
+ return;
}
- };
+ const timer = window.setTimeout(() => setVisibleErrorKey(errorKey), ERROR_INDICATOR_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [errorKey]);
- const loadLatest = () => {
- void run(async () => {
- await onLoadLatest();
- setStatusOpen(false);
- message.success("云端版本已加载,本地内容已保留为草稿");
- }).catch(() => undefined);
- };
-
- const overwriteCloud = () => {
- void run(async () => {
- await overwriteRemoteCanvasProject(projectId);
- setStatusOpen(false);
- message.success("本地版本已覆盖云端");
- }).catch(() => undefined);
- };
+ const showErrorIcon = Boolean(errorKey && visibleErrorKey === errorKey);
+ const saving = phase === "pending" || phase === "saving" || phase === "uploading" || localPhase === "pending";
+ const tone = showErrorIcon && localError ? "error" : cloudError || localError || saving ? "pending" : phase === "done" || localPhase === "saved" ? "done" : "idle";
+ const statusIconClass = showErrorIcon ? undefined : "animate-spin motion-reduce:animate-none";
+ const label = cloudError ? "云端暂未同步" : localError ? "本地暂未保存" : saving ? "正在同步画布" : phase === "done" ? "已同步" : localPhase === "saved" ? "已保存" : "尚未同步";
return (
-
-
-
- {saving || localPending ? : conflict || cloudError || localError ? : }
-
-
- 云端同步
- {label}
-
-
-
- {description}
-
-
-
- 本地浏览器
- {localStatus}
-
-
- 云端画布
- {cloudStatus}
-
- {progress?.pendingWrites ?
待写入 {progress.pendingWrites} 项 : null}
-
- {progress?.draftError ? (
-
- 本地草稿保存未完成,已暂停版本替换
- {progress.draftError}
-
- ) : conflict ? (
-
- 请选择保留哪一版
- {progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "本地内容仍在编辑器中。"} 加载云端版会替换当前编辑内容;用本地版覆盖会替换云端内容。
-
- ) : cloudError ? (
-
- {retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}
- {retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
-
- ) : localError ? (
-
- 本地保存没有完成
- {progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"}
-
- ) : null}
-
- {cloudError ? (
-
}
- loading={busy}
- onClick={() =>
- void run(() => retryRemoteUserDataSync(projectId))
- .then(() => setStatusOpen(false))
- .catch(() => undefined)
- }
- >
- 立即重试云端同步
-
- ) : localError ? (
-
}
- loading={busy}
- onClick={() =>
- void run(() => flushCanvasStorePersistence())
- .then(() => setStatusOpen(false))
- .catch(() => undefined)
- }
- >
- 立即重试本地保存
-
- ) : null}
- {conflict ? (
-
- } disabled={busy || saving || localPending || localError} onClick={loadLatest}>
- 加载云端版
-
- } danger disabled={busy || saving || localPending || localError || Boolean(progress?.draftError)} onClick={overwriteCloud}>
- 用本地版覆盖云端
-
-
- ) : (
-
} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
- 加载云端版
-
- )}
-
- {onOpenVersions ? (
- }
- onClick={() => {
- setStatusOpen(false);
- onOpenVersions();
- }}
- >
- 版本记录{progress?.draftCount ? ` · ${progress.draftCount} 份草稿` : ""}
-
- ) : null}
-
-
-
- }
- >
-
: conflict || cloudError || localError ?
:
}
- >
-
{label}
-
-
+
+ {showErrorIcon ? : saving || cloudError || localError ? : }
+
);
}
diff --git a/web/src/pages/canvas/canvas-version-history.css b/web/src/pages/canvas/canvas-version-history.css
deleted file mode 100644
index df8bda2f4..000000000
--- a/web/src/pages/canvas/canvas-version-history.css
+++ /dev/null
@@ -1,250 +0,0 @@
-.canvas-version-sidebar {
- width: 350px;
- flex: 0 0 350px;
- min-height: 0;
- border-left: 1px solid var(--border);
-}
-.canvas-editor-shell {
- container-type: inline-size;
-}
-@container (max-width: 1050px) {
- .canvas-topbar-tools-cluster > :not(:has(.canvas-topbar-version-button)):not(.canvas-topbar-agent-button) {
- display: none !important;
- }
-}
-@container (max-width: 650px) {
- .canvas-topbar-project-picker {
- display: none !important;
- }
- .canvas-topbar-project-cluster {
- max-width: calc(100% - 180px);
- }
- .canvas-topbar {
- gap: 8px;
- padding-inline: 10px;
- }
- .canvas-sync-status-label {
- display: none;
- }
- .canvas-topbar-project-context {
- display: none;
- }
-}
-.canvas-version-panel {
- height: 100%;
- display: flex;
- flex-direction: column;
- background: var(--sidebar);
- color: var(--foreground);
- font-size: var(--fs-label);
- overscroll-behavior: contain;
-}
-.canvas-version-header {
- display: flex;
- align-items: center;
- gap: 10px;
- min-height: 64px;
- padding: 0 18px;
-}
-.canvas-version-header h2 {
- flex: 1;
- font-size: var(--fs-body);
- font-weight: 600;
-}
-.canvas-version-tabs {
- display: flex;
- margin: 0 18px 14px;
- padding: 3px;
- gap: 3px;
- background: var(--muted);
- border-radius: var(--radius-lg);
-}
-.canvas-version-tabs button {
- display: flex;
- flex: 1;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 9px 4px;
- color: var(--muted-foreground);
- border-radius: var(--radius-md);
-}
-.canvas-version-tabs button[aria-selected="true"] {
- background: var(--background);
- color: var(--foreground);
-}
-.canvas-version-count,
-.canvas-version-tag {
- font-size: var(--fs-tiny);
- color: var(--muted-foreground);
- font-variant-numeric: tabular-nums;
-}
-.canvas-version-tag {
- border-radius: var(--radius-sm);
- background: var(--muted);
- padding: 2px 6px;
- white-space: nowrap;
-}
-.canvas-version-list {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 0 18px 20px;
- overscroll-behavior: contain;
-}
-.canvas-version-current {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 10px;
- text-align: left;
- border: 1px solid var(--border);
- padding: 12px;
- border-radius: var(--radius-lg);
-}
-.canvas-version-current > span:nth-child(2) {
- flex: 1;
- min-width: 0;
-}
-.canvas-version-current strong,
-.canvas-version-current small {
- display: block;
-}
-.canvas-version-dot {
- display: grid;
- place-items: center;
- width: 22px;
- height: 22px;
- border: 1px solid var(--border);
- border-radius: 50%;
-}
-.canvas-version-current[aria-pressed="true"],
-.canvas-version-item[aria-pressed="true"] {
- background: var(--accent);
- border-color: var(--control-focus-ring);
-}
-.canvas-version-panel small {
- display: block;
- margin-top: 5px;
- color: var(--muted-foreground);
- font-size: var(--fs-tiny);
- line-height: 1.6;
-}
-.canvas-version-hint {
- color: var(--muted-foreground);
- font-size: var(--fs-tiny);
- line-height: 1.8;
- margin: 10px 0;
-}
-.canvas-version-error {
- color: var(--destructive);
- line-height: 1.8;
- margin: 10px 0;
-}
-.canvas-version-empty {
- padding: 35px 0;
- text-align: center;
-}
-.canvas-version-group {
- margin-top: 15px;
-}
-.canvas-version-group summary {
- cursor: pointer;
- list-style: none;
- display: flex;
- align-items: center;
- gap: 7px;
- color: var(--muted-foreground);
- padding: 6px 0;
-}
-.canvas-version-group summary::-webkit-details-marker {
- display: none;
-}
-.canvas-version-group:not([open]) summary > svg {
- transform: rotate(-90deg);
-}
-.canvas-version-group summary > span:nth-child(2) {
- flex: 1;
-}
-.canvas-version-branches {
- border-left: 1px solid var(--border);
- margin: 6px 0 0 7px;
- padding-left: 16px;
-}
-.canvas-version-item {
- position: relative;
- width: 100%;
- display: block;
- text-align: left;
- padding: 12px;
- border: 1px solid transparent;
- border-radius: var(--radius-lg);
- margin-bottom: 6px;
-}
-.canvas-version-branches .canvas-version-item::before {
- content: "";
- position: absolute;
- left: -21px;
- top: 19px;
- width: 7px;
- height: 7px;
- border: 1px solid var(--muted-foreground);
- border-radius: 50%;
- background: var(--sidebar);
-}
-.canvas-version-branches .canvas-version-item[aria-pressed="true"]::before {
- background: var(--foreground);
- border-color: var(--foreground);
-}
-.canvas-version-item:hover,
-.canvas-version-current:hover {
- background: var(--accent);
-}
-.canvas-version-item-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 500;
-}
-.canvas-version-item-title > .canvas-version-tag {
- margin-left: auto;
-}
-.canvas-version-reason {
- display: block;
- margin-top: 8px;
-}
-.canvas-version-draft-note {
- color: var(--muted-foreground);
- line-height: 1.8;
- margin: 0 0 15px;
-}
-.canvas-version-draft {
- border-color: var(--border);
- margin-bottom: 10px;
-}
-.canvas-version-draft time {
- display: block;
- color: var(--muted-foreground);
- font-size: var(--fs-tiny);
- margin-top: 10px;
-}
-.canvas-version-footer {
- flex-shrink: 0;
- border-top: 1px solid var(--border);
- padding: 16px 18px;
-}
-.canvas-version-actions {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 14px;
-}
-.canvas-version-panel button:focus-visible,
-.canvas-version-group summary:focus-visible {
- outline: 2px solid var(--ring);
- outline-offset: 2px;
-}
-.canvas-version-panel button:disabled {
- cursor: not-allowed;
- opacity: 0.5;
-}
diff --git a/web/src/pages/canvas/canvas-version-history.tsx b/web/src/pages/canvas/canvas-version-history.tsx
deleted file mode 100644
index 802512d5a..000000000
--- a/web/src/pages/canvas/canvas-version-history.tsx
+++ /dev/null
@@ -1,408 +0,0 @@
-import { AppDrawer } from "@/components/ui/product/app-drawer";
-import { EmptyState } from "@/components/ui/product/empty-state";
-import { App, Button, Grid, Spin } from "antd";
-import { Check, ChevronDown, Cloud, FileClock, History, RefreshCw, X } from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data";
-import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
-import { getActiveUserScope } from "@/lib/user-scope";
-import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
-import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
-import "./canvas-version-history.css";
-
-export type CanvasVersionPreviewState = {
- key: string;
- label: string;
- date: string;
- kind: "cloud" | "draft";
- snapshot?: CanvasHistoryEntry;
- project?: CanvasProject;
- error?: string;
-};
-
-export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotId: string, revision: number) => Promise
) {
- const { message, modal } = App.useApp();
- const desktop = Boolean(Grid.useBreakpoint().lg);
- const scope = getActiveUserScope();
- const contextRef = useRef({ projectId, scope });
- contextRef.current = { projectId, scope };
- const isCurrentContext = () => contextRef.current.projectId === projectId && contextRef.current.scope === scope && getActiveUserScope() === scope;
- const draftCount = useSyncProgressStore((state) => state.syncingProjects[projectId]?.draftCount);
- const [open, setOpen] = useState(false);
- const [listOpen, setListOpen] = useState(true);
- const [tab, setTab] = useState<"cloud" | "draft">("cloud");
- const [entries, setEntries] = useState([]);
- const [drafts, setDrafts] = useState([]);
- const [currentRevision, setCurrentRevision] = useState();
- const [preview, setPreview] = useState(null);
- const [loading, setLoading] = useState(false);
- const [draftLoading, setDraftLoading] = useState(false);
- const [restoring, setRestoring] = useState(false);
- const [confirming, setConfirming] = useState(false);
- const [error, setError] = useState("");
- const [draftError, setDraftError] = useState("");
- const [reload, setReload] = useState(0);
- const previewRequest = useRef(null);
- const returnToCurrent = useCallback(() => {
- previewRequest.current?.abort();
- setPreview(null);
- }, []);
- const close = useCallback(() => {
- if (!restoring) {
- setOpen(false);
- returnToCurrent();
- }
- }, [restoring, returnToCurrent]);
-
- useEffect(() => {
- setOpen(false);
- setTab("cloud");
- returnToCurrent();
- }, [projectId, scope, returnToCurrent]);
-
- useEffect(() => {
- if (!open) return;
- const controller = new AbortController();
- setLoading(true);
- setError("");
- setEntries([]);
- setCurrentRevision(undefined);
- void listCanvasHistory(projectId, controller.signal)
- .then((result) => {
- if (controller.signal.aborted || !isCurrentContext()) return;
- setEntries(result.snapshots);
- // Keep the revision observed with this list as the restore precondition.
- setCurrentRevision(result.currentRevision);
- })
- .catch((cause) => {
- if (!controller.signal.aborted && isCurrentContext()) setError(cause instanceof Error ? cause.message : "历史版本读取失败");
- })
- .finally(() => {
- if (!controller.signal.aborted && isCurrentContext()) setLoading(false);
- });
- return () => controller.abort();
- }, [open, projectId, reload, scope]);
-
- useEffect(() => {
- if (!open) return;
- let active = true;
- setDraftLoading(true);
- setDraftError("");
- setDrafts([]);
- void readCanvasSyncDrafts(projectId, scope)
- .then((items) => {
- if (active && isCurrentContext()) setDrafts(items.sort((a, b) => b.savedAt.localeCompare(a.savedAt)));
- })
- .catch(() => {
- if (active && isCurrentContext()) setDraftError("本地草稿读取失败,请刷新重试");
- })
- .finally(() => {
- if (active && isCurrentContext()) setDraftLoading(false);
- });
- return () => {
- active = false;
- };
- }, [open, projectId, reload, scope, draftCount]);
-
- useEffect(
- () => () => {
- previewRequest.current?.abort();
- },
- [open, projectId, scope],
- );
-
- const selectVersion = (entry: CanvasHistoryEntry) => {
- previewRequest.current?.abort();
- const controller = new AbortController();
- previewRequest.current = controller;
- const selection: CanvasVersionPreviewState = { key: entry.id, label: `版本 ${entry.revision}`, date: entry.contentUpdatedAt, kind: "cloud", snapshot: entry };
- setPreview(selection);
- setListOpen(false);
- void getCanvasHistoryEntry(projectId, entry.id, controller.signal)
- .then(({ project }) => {
- if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, project });
- })
- .catch((cause) => {
- if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, error: cause instanceof Error ? cause.message : "预览读取失败,请重新选择版本" });
- });
- };
-
- const restore = () => {
- const selected = preview?.snapshot;
- if (!selected || !preview.project || currentRevision === undefined || restoring) return;
- setConfirming(true);
- modal.confirm({
- title: `恢复版本 ${selected.revision} 的内容?`,
- content: "恢复前会备份当前云端内容,并保留本地草稿。恢复后会生成一个新版本,画布的项目归属保持当前设置。",
- okText: "恢复此版本",
- cancelText: "取消",
- afterClose: () => setConfirming(false),
- onOk: async () => {
- if (!isCurrentContext()) {
- message.error("画布或账号已切换,请重新打开版本记录");
- return;
- }
- setRestoring(true);
- try {
- await onRestore(selected.id, currentRevision);
- returnToCurrent();
- setReload((value) => value + 1);
- message.success("已恢复内容并保存为新版本");
- } catch (cause) {
- const detail = cause instanceof Error ? cause.message : "恢复失败,请重试";
- setError(detail);
- message.error(detail);
- } finally {
- setRestoring(false);
- }
- },
- });
- };
-
- return {
- open,
- listOpen,
- desktop,
- tab,
- entries,
- drafts,
- currentRevision,
- preview,
- loading,
- draftLoading,
- restoring,
- confirming,
- error,
- draftError,
- show: () => {
- setOpen(true);
- setListOpen(true);
- },
- toggle: () => {
- if (open && (desktop || listOpen)) close();
- else {
- setOpen(true);
- setListOpen(true);
- }
- },
- hideList: () => {
- if (!restoring) {
- setListOpen(false);
- if (!preview) setOpen(false);
- }
- },
- close,
- changeTab: (next: "cloud" | "draft") => {
- setTab(next);
- returnToCurrent();
- },
- refresh: () => {
- returnToCurrent();
- setReload((value) => value + 1);
- },
- selectVersion,
- selectDraft: (draft: CanvasSyncDraft) => {
- previewRequest.current?.abort();
- setPreview({ key: draft.id, label: "本地草稿", date: draft.savedAt, kind: "draft", project: draft.project });
- setListOpen(false);
- },
- returnToCurrent,
- restore,
- };
-}
-
-export type CanvasVersionHistoryController = ReturnType;
-
-export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) {
- const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, error, draftError } = history;
- if (!open) return null;
- const groups = new Map();
- for (const entry of entries) {
- const day = new Date(entry.contentUpdatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
- groups.set(day, [...(groups.get(day) || []), entry]);
- }
- const content = (
-
-
-
- 版本记录
- } disabled={restoring || loading} onClick={history.refresh} />
- } disabled={restoring} onClick={history.close} />
-
-
{
- if (restoring || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
- event.preventDefault();
- const next = event.key === "Home" ? "cloud" : event.key === "End" ? "draft" : tab === "cloud" ? "draft" : "cloud";
- history.changeTab(next);
- event.currentTarget.querySelector(`[data-tab="${next}"]`)?.focus();
- }}
- >
- history.changeTab("cloud")}
- >
-
- 云端历史
-
- history.changeTab("draft")}
- >
-
- 本地草稿 {drafts.length}
-
-
-
- {tab === "cloud" ? (
- <>
-
-
-
-
-
- 当前画布
- {currentRevision === undefined ? "云端版本待确认" : `打开记录时云端为 v${currentRevision}`}
-
- {!preview ? 当前 : null}
-
-
查看旧版不会修改当前画布
- {error ? (
-
- {error}
-
- ) : null}
- {loading ? (
-
-
-
- ) : !entries.length && !error ? (
-
- ) : null}
- {[...groups].map(([day, items]) => (
-
-
-
- {day}
- {items.length}
-
-
- {items.map((entry) => (
- history.selectVersion(entry)}>
-
-
- v{entry.revision}
-
- {entry.reason === "before_restore" ? "恢复前备份" : "自动保存"}
-
- {entry.nodeCount} 个节点 · {entry.connectionCount} 条连线
-
-
- ))}
-
-
- ))}
- >
- ) : (
- <>
-
发生冲突或恢复版本时,系统自动保留的本地内容,可在此查看。
- {draftError ? (
-
- {draftError}
-
- ) : null}
- {draftLoading ? (
-
-
-
- ) : !drafts.length && !draftError ? (
-
- ) : null}
- {drafts.map((draft) => (
-
history.selectDraft(draft)}>
-
-
- 本机备份
- {draft.project.revision !== undefined ? 基于 v{draft.project.revision} : null}
-
-
-
- {draft.project.nodes.length} 个节点 · {draft.project.connections.length} 条连线
-
-
- ))}
- >
- )}
-
-
-
- );
-
- return history.desktop ? (
-
- ) : (
-
- {content}
-
- );
-}
-
-function formatTime(date: string) {
- return new Date(date).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
-}
diff --git a/web/src/pages/canvas/canvas-version-preview.tsx b/web/src/pages/canvas/canvas-version-preview.tsx
deleted file mode 100644
index 94c4d934d..000000000
--- a/web/src/pages/canvas/canvas-version-preview.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { EmptyState } from "@/components/ui/product/empty-state";
-import { Button, Spin } from "antd";
-import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react";
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { CanvasViewport } from "@/components/canvas/canvas-viewport";
-import { CanvasNode } from "@/components/canvas/canvas-node";
-import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
-import { ConnectionPath } from "@/components/canvas/canvas-connections";
-import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
-import { CanvasNodeGraphContext } from "@/components/canvas/canvas-node-graph-context";
-import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
-import { getCanvasNodesBounds, viewportAtScale, viewportForBounds } from "@/lib/canvas/canvas-viewport";
-import { resolveCanvasAppearance } from "@/lib/canvas/canvas-appearance";
-import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
-import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
-import { resolveMediaUrl } from "@/services/file-storage";
-import type { CanvasNodeData, ViewportTransform } from "@/types/canvas";
-import type { CanvasVersionPreviewState } from "./canvas-version-history";
-
-const noAction = () => undefined;
-const readOnlyActions = {};
-const previewNodeId = (id: string) => `version-preview:${id}`;
-
-export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { preview: CanvasVersionPreviewState; onReturn: () => void; onShowVersions: () => void }) {
- const containerRef = useRef(null);
- const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 });
- const [selectedId, setSelectedId] = useState();
- const [localMedia, setLocalMedia] = useState>({});
- const [mediaError, setMediaError] = useState("");
- const theme = useActiveTheme();
- const project = preview.project;
- useEffect(() => {
- const node = project?.nodes.find((item) => item.id === selectedId);
- const key = node?.metadata?.storageKey;
- if (!node || !key || resourceIdFromStorageKey(key) || !["video", "audio"].includes(node.type)) return;
- let active = true;
- setMediaError("");
- void resolveMediaUrl(key, node.metadata?.content)
- .then((url) => {
- if (active) setLocalMedia((current) => ({ ...current, [node.id]: url }));
- })
- .catch(() => {
- if (active) setMediaError("媒体暂时无法读取,请重新打开此版本重试");
- });
- return () => {
- active = false;
- };
- }, [project, selectedId]);
- const nodes = useMemo(
- () =>
- (project?.nodes || []).map((node) => {
- const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
- return resourceId && ["image", "video", "audio"].includes(node.type)
- ? { ...node, metadata: { ...node.metadata, content: resourceFileUrl(resourceId) } }
- : localMedia[node.id]
- ? { ...node, metadata: { ...node.metadata, content: localMedia[node.id] } }
- : node;
- }),
- [project, localMedia],
- );
- const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
- const connections = useMemo(
- () =>
- (project?.connections || []).flatMap((connection) => {
- const resolved = resolveFrameConnection(connection, nodes);
- return resolved ? [{ connection, ...resolved }] : [];
- }),
- [project, nodes],
- );
- const graph = useMemo(() => {
- const byId = new Map(nodes.map((node) => [node.id, node]));
- return {
- getUpstreamNodes: (id: string) =>
- (project?.connections || [])
- .filter((connection) => previewNodeId(connection.toNodeId) === id)
- .flatMap((connection) => {
- const node = byId.get(connection.fromNodeId);
- return node ? [node] : [];
- }),
- };
- }, [nodes, project]);
- const fit = useCallback(() => {
- const rect = containerRef.current?.getBoundingClientRect();
- const sourceNodes = project?.nodes || [];
- const bounds = getCanvasNodesBounds(sourceNodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, sourceNodes)));
- if (rect && bounds) setViewport(viewportForBounds(bounds, rect, { padding: 72 }));
- }, [project]);
- useLayoutEffect(() => {
- fit();
- }, [fit]);
- const zoom = (scale: number) => {
- const rect = containerRef.current?.getBoundingClientRect();
- if (rect) setViewport((current) => viewportAtScale(current, rect, scale));
- };
-
- return (
-
-
- {preview.error ? (
-
-
{preview.error}
-
重新选择版本
-
- ) : !project ? (
-
- ) : (
-
- {/* No editor callbacks, drawing cache key, or action context may cross this boundary. */}
-
-
- event.preventDefault()}
- onDrop={(event) => event.preventDefault()}
- onCanvasDeselect={() => setSelectedId(undefined)}
- >
-
- {visibleNodes.map((node) =>
- isFrameNode(node) ? (
- child.parentId === node.id)}
- scale={viewport.k}
- isSelected={false}
- isDropTarget={false}
- readOnly
- onMouseDown={noAction}
- onResize={noAction}
- onToggleCollapsed={noAction}
- onFolderStyleChange={noAction}
- onTitleChange={noAction}
- onContextMenu={(event) => event.preventDefault()}
- />
- ) : (
- setSelectedId(node.id)}
- onHoverStart={noAction}
- onHoverEnd={noAction}
- onConnectStart={noAction}
- onResize={noAction}
- onContentChange={noAction}
- onContextMenu={(event) => event.preventDefault()}
- />
- ),
- )}
-
-
-
- {!nodes.length ? (
-
-
-
- ) : null}
- {mediaError ? (
-
- {mediaError}
-
- ) : null}
-
- } onClick={() => zoom(viewport.k / 1.2)} />
-
- {Math.round(viewport.k * 100)}%
-
- } onClick={() => zoom(viewport.k * 1.2)} />
- } onClick={fit} />
-
-
- )}
-
- );
-}
-
-function renderReadOnlyContent(node: CanvasNodeData) {
- const rows = node.metadata?.storyboard?.rows;
- return (
-
-
{node.title}
-
- {rows?.length
- ? rows.map((row) => (
-
-
- 镜头 {row.shotNumber} · {row.durationSeconds}s
-
-
{row.plotDescription}
-
{row.dialogue}
-
- ))
- : node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || "此节点没有文本内容"}
-
-
- );
-}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 2fd2627f7..18869df73 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -1,4 +1,6 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
+import "@/lib/plugins/builtin/ai-art-critique";
+import "@/lib/plugins/builtin/media-conversion";
import { useCanvasImageLayerGroups } from "./use-canvas-image-layer-groups";
import { useCanvasImageLayerMaterials } from "./use-canvas-image-layer-materials";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
@@ -21,7 +23,6 @@ import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
-import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { filterCanvasDisplayConnections, persistCanvasHideNodeConnections, readCanvasHideNodeConnections } from "@/lib/canvas/canvas-connection-visibility";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
@@ -77,7 +78,6 @@ import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generat
import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { CanvasPrevisNodePanel } from "@/components/canvas/previs/canvas-previs-node-panel";
-import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
import { useFocusMode } from "@/hooks/use-focus-mode";
import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
@@ -110,10 +110,8 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
-import { CanvasSyncStatus } from "./canvas-sync-status";
-import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
-import { CanvasVersionPreview } from "./canvas-version-preview";
import { CanvasTopBar } from "./canvas-project-top-bar";
+import { CanvasSyncStatus } from "./canvas-sync-status";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
@@ -163,7 +161,6 @@ import {
type CanvasFolderTheme,
type CanvasNodeData,
type CanvasNodeMetadata,
- type CanvasMediaPerformanceMode,
type StoryboardColumn,
type StoryboardShotCount,
type StoryboardShotDuration,
@@ -251,7 +248,6 @@ function CanvasViewportPage() {
const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE);
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState("box-select");
- const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode);
const [hideNodeConnections, setHideNodeConnections] = useState(readCanvasHideNodeConnections);
const [projectLoaded, setProjectLoaded] = useState(false);
const workspaceMode: CanvasWorkspaceMode = "professional";
@@ -282,7 +278,6 @@ function CanvasViewportPage() {
const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
const [scriptScrollTopById, setScriptScrollTopById] = useState>({});
const [previsNodeId, setPrevisNodeId] = useState(null);
- const [versionCompareRootId, setVersionCompareRootId] = useState(null);
const [libTVImportOpen, setLibTVImportOpen] = useState(false);
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
@@ -295,10 +290,6 @@ function CanvasViewportPage() {
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
- useEffect(() => {
- persistCanvasMediaPerformanceMode(mediaPerformanceMode);
- }, [mediaPerformanceMode]);
-
useEffect(() => {
persistCanvasHideNodeConnections(hideNodeConnections);
}, [hideNodeConnections]);
@@ -378,7 +369,7 @@ function CanvasViewportPage() {
[cleanupAssetImages, getHistoryCleanupContext],
);
- const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
+ const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
@@ -409,59 +400,54 @@ function CanvasViewportPage() {
cleanupCanvasFiles,
});
- const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
- const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
- const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
+ const openAgent = useCallback(() => {
+ openAssistant();
+ }, [openAssistant]);
- const sendSelectionToAgent = useCallback((nodeId?: string) => {
- const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
- const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
- if (!references.length) return;
- if (nodeId && !selectedNodeIdsRef.current.has(nodeId)) {
- const selection = new Set([nodeId]);
- selectedNodeIdsRef.current = selection;
- setSelectedNodeIds(selection);
- }
- const text = `${references.map(canvasResourceMentionToken).join(" ")} `;
- setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text }));
- openAgent();
- setContextMenu(null);
- }, [agentMentionReferences, openAgent]);
-
- const sendImageToPrevisAgent = useCallback((node: CanvasNodeData) => {
- if (node.type !== CanvasNodeType.Image) return;
- const reference = agentMentionReferences.find((item) => item.nodeId === node.id && item.kind === "image");
- if (!reference) {
- message.warning("这张图片暂时没有可供 Agent 引用的画布资源");
- return;
- }
- if (!selectedNodeIdsRef.current.has(node.id)) {
- const selection = new Set([node.id]);
- selectedNodeIdsRef.current = selection;
- setSelectedNodeIds(selection);
- }
- setAgentPrefillRequest((current) => ({
- id: (current?.id ?? 0) + 1,
- text: buildImageToPrevisAgentPrompt(reference),
- displayText: buildImageToPrevisDisplayText(),
- canvasReferenceNodeId: reference.nodeId,
- autoSubmit: true,
- requiresVision: true,
- }));
- openAgent();
- setContextMenu(null);
- }, [agentMentionReferences, message, openAgent]);
- // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
- const confirmForceSaveCanvas = useCallback(() => {
- modal.confirm({
- title: "修复素材关联并保存?",
- content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
- okText: "修复并保存",
- cancelText: "取消",
- onOk: () => forceSaveCanvasProject(),
- });
- }, [forceSaveCanvasProject, modal]);
+ const sendSelectionToAgent = useCallback(
+ (nodeId?: string) => {
+ const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
+ const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
+ if (!references.length) return;
+ if (nodeId && !selectedNodeIdsRef.current.has(nodeId)) {
+ const selection = new Set([nodeId]);
+ selectedNodeIdsRef.current = selection;
+ setSelectedNodeIds(selection);
+ }
+ const text = `${references.map(canvasResourceMentionToken).join(" ")} `;
+ setAgentPrefillRequest((current) => ({ id: (current?.id ?? 0) + 1, text }));
+ openAgent();
+ setContextMenu(null);
+ },
+ [agentMentionReferences, openAgent],
+ );
+ const sendImageToPrevisAgent = useCallback(
+ (node: CanvasNodeData) => {
+ if (node.type !== CanvasNodeType.Image) return;
+ const reference = agentMentionReferences.find((item) => item.nodeId === node.id && item.kind === "image");
+ if (!reference) {
+ message.warning("这张图片暂时没有可供 Agent 引用的画布资源");
+ return;
+ }
+ if (!selectedNodeIdsRef.current.has(node.id)) {
+ const selection = new Set([node.id]);
+ selectedNodeIdsRef.current = selection;
+ setSelectedNodeIds(selection);
+ }
+ setAgentPrefillRequest((current) => ({
+ id: (current?.id ?? 0) + 1,
+ text: buildImageToPrevisAgentPrompt(reference),
+ displayText: buildImageToPrevisDisplayText(),
+ canvasReferenceNodeId: reference.nodeId,
+ autoSubmit: true,
+ requiresVision: true,
+ }));
+ openAgent();
+ setContextMenu(null);
+ },
+ [agentMentionReferences, message, openAgent],
+ );
const applyLibTVImport = useCallback(
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
const previousNodes = nodesRef.current;
@@ -956,7 +942,6 @@ function CanvasViewportPage() {
setScriptEditorNodeId(clearDeletedId);
setArtCritiqueNodeId(clearDeletedId);
setPrevisNodeId(clearDeletedId);
- setVersionCompareRootId(clearDeletedId);
setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current));
const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
@@ -1002,7 +987,6 @@ function CanvasViewportPage() {
pasteCopiedNodes,
restoreCopiedNodesFromText,
releaseCopiedNodesPastePriority,
- setPrimaryVersion,
shouldPreferCopiedNodes,
toggleNodeLocked,
} = useCanvasNodeOperations({
@@ -1181,7 +1165,14 @@ function CanvasViewportPage() {
const handleNodeDragEnd = useCallback(
(nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
- if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE || (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId)) {
+ if (
+ !node ||
+ node.type === CanvasNodeType.Script ||
+ node.type === CanvasNodeType.Drawing ||
+ node.type === CanvasNodeType.Panorama ||
+ node.type === ART_CRITIQUE_NODE_TYPE ||
+ (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId)
+ ) {
setDialogNodeId(null);
return;
}
@@ -1347,13 +1338,7 @@ function CanvasViewportPage() {
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
- const storageKey = characterCover
- ? `resource:${characterCover.resourceId}`
- : local?.kind === "image" || local?.kind === "video" || local?.kind === "audio"
- ? local.data.storageKey
- : remoteResourceId
- ? asset.storageKey
- : undefined;
+ const storageKey = characterCover ? `resource:${characterCover.resourceId}` : local?.kind === "image" || local?.kind === "video" || local?.kind === "audio" ? local.data.storageKey : remoteResourceId ? asset.storageKey : undefined;
const content = characterCover
? resourceFileUrl(characterCover.resourceId)
: local?.kind === "image"
@@ -1417,7 +1402,6 @@ function CanvasViewportPage() {
nodeById,
nodeRenderLODById,
previewNode,
- reduceMediaEffects,
relatedHighlight,
resourceReferenceByNodeId,
selectedNodeBounds,
@@ -1426,7 +1410,6 @@ function CanvasViewportPage() {
superResolveNode,
toolbarNode,
upscaleNode,
- versionCompareNodes,
visibleNodes,
} = useCanvasRenderModel({
nodes,
@@ -1434,7 +1417,6 @@ function CanvasViewportPage() {
assets,
viewport,
viewportSize: size,
- mediaPerformanceMode,
selectedNodeIds,
hoveredNodeId,
dragPreview,
@@ -1454,20 +1436,20 @@ function CanvasViewportPage() {
emotionNodeId,
previewNodeId,
contextMenu,
- versionCompareRootId,
previsNodeId,
scriptEditorNodeId,
dialogNodeId,
});
const visibleDisplayConnections = useMemo(
- () => filterCanvasDisplayConnections(displayConnections, {
- enabled: hideNodeConnections,
- hoveredNodeId,
- selectedNodeIds,
- activeNodeId,
- selectedConnectionId,
- }),
+ () =>
+ filterCanvasDisplayConnections(displayConnections, {
+ enabled: hideNodeConnections,
+ hoveredNodeId,
+ selectedNodeIds,
+ activeNodeId,
+ selectedConnectionId,
+ }),
[activeNodeId, displayConnections, hideNodeConnections, hoveredNodeId, selectedConnectionId, selectedNodeIds],
);
useEffect(() => {
@@ -1696,7 +1678,7 @@ function CanvasViewportPage() {
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
useCanvasKeyboard({
- enabled: projectLoaded && !versions.preview,
+ enabled: projectLoaded,
nodesRef,
selectedNodeIdsRef,
selectedConnectionId,
@@ -1732,7 +1714,6 @@ function CanvasViewportPage() {
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
});
-
const startTitleEditing = useCallback(() => {
setTitleDraft(currentProject?.title || "未命名画布");
setTitleEditing(true);
@@ -1774,13 +1755,11 @@ 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.
- const copySource = resourceId
- ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
- : content || "";
+ const copySource = resourceId ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) : content || "";
if (!copySource) throw new Error("没有可复制的内容");
if (node.type === CanvasNodeType.Image) {
try {
@@ -1822,9 +1801,7 @@ function CanvasViewportPage() {
const content = node?.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(storageKey);
const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : "";
- const mediaURL = resourceId
- ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url)
- : mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
+ const mediaURL = resourceId ? resolveResourceAccessURL((await getResourceAccess(`resource:${resourceId}`, "copy")).url) : mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址");
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL);
else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板");
@@ -1986,32 +1963,37 @@ function CanvasViewportPage() {
});
return changed ? next : current;
});
- nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
- syncRowsFromConnections(node.id, true);
- });
+ nodesRef.current
+ .filter((node) => node.type === CanvasNodeType.BatchTable)
+ .forEach((node) => {
+ syncRowsFromConnections(node.id, true);
+ });
}, [connections, projectLoaded, setNodes, syncRowsFromConnections]);
- const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
- const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
- const table = tableNode?.metadata?.batchTable;
- const row = table?.rows.find((item) => item.id === rowId);
- if (!tableNode || !table || !row) return;
- const existingId = row.inputNodeIds[columnIndex];
- if (existingId) {
- await replaceNodeMedia(existingId, file);
- return;
- }
- const rowIndex = table.rows.findIndex((item) => item.id === rowId);
- const uploadedNodeId = await createFileNode(file, {
- x: tableNode.position.x - 240,
- y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
- });
- if (!uploadedNodeId) return;
- const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
- inputNodeIds[columnIndex] = uploadedNodeId;
- updateBatchRow(tableNodeId, rowId, { inputNodeIds });
- message.success(`已上传并填入参考图 ${columnIndex + 1}`);
- }, [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow]);
+ const handleUploadBatchReference = useCallback(
+ async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
+ const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
+ const table = tableNode?.metadata?.batchTable;
+ const row = table?.rows.find((item) => item.id === rowId);
+ if (!tableNode || !table || !row) return;
+ const existingId = row.inputNodeIds[columnIndex];
+ if (existingId) {
+ await replaceNodeMedia(existingId, file);
+ return;
+ }
+ const rowIndex = table.rows.findIndex((item) => item.id === rowId);
+ const uploadedNodeId = await createFileNode(file, {
+ x: tableNode.position.x - 240,
+ y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
+ });
+ if (!uploadedNodeId) return;
+ const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
+ inputNodeIds[columnIndex] = uploadedNodeId;
+ updateBatchRow(tableNodeId, rowId, { inputNodeIds });
+ message.success(`已上传并填入参考图 ${columnIndex + 1}`);
+ },
+ [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow],
+ );
const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
useCanvasStoryboard({
@@ -2025,57 +2007,75 @@ function CanvasViewportPage() {
enqueueGenerationBatch,
});
- const createStoryboardFromBatchTable = useCallback(async (tableNodeId: string) => {
- const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable);
- const table = tableNode?.metadata?.batchTable;
- if (!tableNode || !table || table.contentKind !== "storyboard") return;
- const rows = storyboardRowsFromBatchTable(table);
- if (!rows.length) return message.warning("当前表格没有可用的分镜行");
- const sourceIds = Array.from(new Set([
- ...(table.storyboardSourceNodeIds || []),
- ...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId),
- ])).filter((id) => nodesRef.current.some((node) => node.id === id));
- const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video);
- const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined);
- const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length);
- const scriptSpec = getNodeSpec(CanvasNodeType.Script);
- const scriptNode = createCanvasNode(CanvasNodeType.Script, {
- x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2,
- y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2,
- }, {
- storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds },
- storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct",
- workflowKind: "storyboard",
- workflowTitle: table.storyboardTitle || "视频脚本",
- composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本",
- status: "idle",
- });
- scriptNode.title = table.storyboardTitle || "视频脚本";
- const nextConnections = [
- ...connectionsRef.current,
- ...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const })),
- ];
- const nextNodes = [...nodesRef.current, scriptNode];
- nodesRef.current = nextNodes;
- connectionsRef.current = nextConnections;
- setNodes(nextNodes);
- setConnections(nextConnections);
- setSelectedNodeIds(new Set([scriptNode.id]));
- setDialogNodeId(null);
- if (sourceVideo && keyframeTimesMs.length) {
- const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs });
- if (frameNodes.length) {
- const withFrames = nodesRef.current.map((node) => node.id === scriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { ...node.metadata?.storyboard, rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id), visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds } } } : node);
- nodesRef.current = withFrames;
- setNodes(withFrames);
- message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`);
+ const createStoryboardFromBatchTable = useCallback(
+ async (tableNodeId: string) => {
+ const tableNode = nodesRef.current.find((node) => node.id === tableNodeId && node.type === CanvasNodeType.BatchTable);
+ const table = tableNode?.metadata?.batchTable;
+ if (!tableNode || !table || table.contentKind !== "storyboard") return;
+ const rows = storyboardRowsFromBatchTable(table);
+ if (!rows.length) return message.warning("当前表格没有可用的分镜行");
+ const sourceIds = Array.from(
+ new Set([...(table.storyboardSourceNodeIds || []), ...connectionsRef.current.filter((connection) => connection.toNodeId === tableNodeId && connection.relation !== "batch-output").map((connection) => connection.fromNodeId)]),
+ ).filter((id) => nodesRef.current.some((node) => node.id === id));
+ const sourceVideo = sourceIds.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => node?.type === CanvasNodeType.Video);
+ const keyframeTimesMs = rows.map(storyboardKeyframeTime).filter((value): value is number => value !== undefined);
+ const useKeyframes = Boolean(sourceVideo && keyframeTimesMs.length);
+ const scriptSpec = getNodeSpec(CanvasNodeType.Script);
+ const scriptNode = createCanvasNode(
+ CanvasNodeType.Script,
+ {
+ x: tableNode.position.x + tableNode.width + 160 + scriptSpec.width / 2,
+ y: tableNode.position.y + Math.max(tableNode.height, scriptSpec.height) / 2,
+ },
+ {
+ storyboard: { rows, visibleColumns: cinematicStoryboardColumns(), referenceNodeIds: sourceIds },
+ storyboardVideoInputMode: useKeyframes ? "keyframe" : "direct",
+ workflowKind: "storyboard",
+ workflowTitle: table.storyboardTitle || "视频脚本",
+ composerContent: table.storyboardTitle || "来自多维表格的视频分镜脚本",
+ status: "idle",
+ },
+ );
+ scriptNode.title = table.storyboardTitle || "视频脚本";
+ const nextConnections = [...connectionsRef.current, ...sourceIds.map((sourceId) => ({ id: nanoid(), fromNodeId: sourceId, toNodeId: scriptNode.id, toHandleId: "storyboard:context", relation: "storyboard-asset-reference" as const }))];
+ const nextNodes = [...nodesRef.current, scriptNode];
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ setSelectedNodeIds(new Set([scriptNode.id]));
+ setDialogNodeId(null);
+ if (sourceVideo && keyframeTimesMs.length) {
+ const frameNodes = await extractVideoFrames(sourceVideo, { timesMs: keyframeTimesMs });
+ if (frameNodes.length) {
+ const withFrames = nodesRef.current.map((node) =>
+ node.id === scriptNode.id
+ ? {
+ ...node,
+ metadata: {
+ ...node.metadata,
+ storyboard: {
+ ...node.metadata?.storyboard,
+ rows: bindStoryboardKeyframes(node.metadata?.storyboard?.rows || rows, frameNodes, sourceVideo.id),
+ visibleColumns: cinematicStoryboardColumns(),
+ referenceNodeIds: sourceIds,
+ },
+ },
+ }
+ : node,
+ );
+ nodesRef.current = withFrames;
+ setNodes(withFrames);
+ message.success(`已创建视频脚本并自动抽取 ${frameNodes.length} 个关键帧,点击“生成视频”继续`);
+ } else {
+ message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取");
+ }
} else {
- message.warning("视频脚本已创建,但关键帧抽取失败;请检查视频是否已保存或允许跨域读取");
+ message.success(`已创建视频脚本:${rows.length} 个镜头`);
}
- } else {
- message.success(`已创建视频脚本:${rows.length} 个镜头`);
- }
- }, [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
+ },
+ [connectionsRef, extractVideoFrames, message, nodesRef, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds],
+ );
const handleRetryNode = useCanvasGenerationRetry({
projectId,
@@ -2196,7 +2196,9 @@ function CanvasViewportPage() {
isRunning={isCanvasNodeGenerating(panelNode, runningNodeId)}
mentionReferences={[
...(mentionReferencesByNodeId.get(panelNode.id) || EMPTY_RESOURCE_REFERENCES),
- ...buildCanvasResourceReferences(nodesRef.current, connectionsRef.current).filter((reference) => reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)),
+ ...buildCanvasResourceReferences(nodesRef.current, connectionsRef.current).filter(
+ (reference) => reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId),
+ ),
]}
onAddReference={(nodeId, reference) => {
if (reference.active || reference.assetId || reference.kind === "skill") return reference;
@@ -2299,7 +2301,9 @@ function CanvasViewportPage() {
onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)}
onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
- onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}
+ onUploadReference={(rowId, columnIndex, file) => {
+ void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file);
+ }}
onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
/>
@@ -2481,7 +2485,6 @@ function CanvasViewportPage() {
},
[openNodeTaskDetails],
);
- const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []);
const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
const editCanvasPrevis = useCallback((node: CanvasNodeData) => openPrevisWorkbench(node.id), [openPrevisWorkbench]);
const locateProjectStyleNode = useCallback(() => {
@@ -2564,844 +2567,864 @@ function CanvasViewportPage() {
>
跳转到画布主内容
-
- {!focusMode && !versions.preview ? setWorkspaceOpen(true)} onInsertAssets={handleProjectAssetsInsert} projectId={projectId} nodes={nodes} selectedNodeIds={selectedNodeIds} onClose={() => setWorkspaceOpen(false)} onAssets={() => openCanvasAssetLibrary()} onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} onCancelTask={cancelCanvasTask} onFocus={nodeId => {
- const target = nodeById.get(nodeId);
- const parent = target?.parentId ? nodeById.get(target.parentId) : null;
- if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
- const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
- if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
- const selection = new Set([nodeId]); selectedNodeIdsRef.current = selection; setSelectedNodeIds(selection); setSelectedConnectionId(null); focusCanvasNode(nodeId);
- }} /> : null}
- {!workspaceOpen && !focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
+
+ {!focusMode ? (
+ setWorkspaceOpen(true)}
+ onInsertAssets={handleProjectAssetsInsert}
+ projectId={projectId}
+ nodes={nodes}
+ selectedNodeIds={selectedNodeIds}
+ onClose={() => setWorkspaceOpen(false)}
+ onAssets={() => openCanvasAssetLibrary()}
+ onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined}
+ onCancelTask={cancelCanvasTask}
+ onFocus={(nodeId) => {
+ const target = nodeById.get(nodeId);
+ const parent = target?.parentId ? nodeById.get(target.parentId) : null;
+ if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
+ const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
+ if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
+ const selection = new Set([nodeId]);
+ selectedNodeIdsRef.current = selection;
+ setSelectedNodeIds(selection);
+ setSelectedConnectionId(null);
+ focusCanvasNode(nodeId);
+ }}
+ />
+ ) : null}
+ {!workspaceOpen && !focusMode && shortDramaEnabled && currentProject?.projectId ? (
openProjectAssets()} />
) : null}
-
- {!focusMode ? (
- }
- versionsOpen={versions.open}
- onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
- title={currentProject?.title || "未命名画布"}
- titleDraft={titleDraft}
- isTitleEditing={titleEditing}
- onTitleDraftChange={setTitleDraft}
- onStartTitleEditing={startTitleEditing}
- onFinishTitleEditing={finishTitleEditing}
- onCancelTitleEditing={() => setTitleEditing(false)}
- canUndo={historyState.canUndo}
- canRedo={historyState.canRedo}
- onCreateProject={createAndOpenProject}
- onDeleteProject={deleteCurrentProject}
- onSave={() => void saveCanvasProject()}
- onForceSave={confirmForceSaveCanvas}
- onImportImage={() => handleUploadRequest()}
- onImportLibTV={() => setLibTVImportOpen(true)}
- onImportTapNow={() => setTapNowImportOpen(true)}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onShare={() => setShareModalOpen(true)}
- shortcutRequestNonce={shortcutRequestNonce}
- mediaPerformanceMode={mediaPerformanceMode}
- onMediaPerformanceModeChange={setMediaPerformanceMode}
- onOpenSearch={() => setNodeSearchOpen(true)}
- projectContext={
- shortDramaEnabled && currentProject?.projectId
- ? {
- ...canvasContext,
- projectId: currentProject.projectId,
- projectName: linkedProjectQuery.data?.project.name || currentProject.title,
- }
- : undefined
- }
- onEnterFocusMode={enterFocusMode}
- shortDramaGuide={shortDramaGuide}
- />
- ) : null}
-
- setNodeSearchOpen(false)}
- onFocus={(nodeId) => {
- const target = nodeById.get(nodeId);
- const parent = target?.parentId ? nodeById.get(target.parentId) : null;
- if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
- const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
- if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
- const selection = new Set([nodeId]);
- selectedNodeIdsRef.current = selection;
- setSelectedNodeIds(selection);
- setSelectedConnectionId(null);
- focusCanvasNode(nodeId);
- }}
- />
-
- {!focusMode && shortDramaGuide ? (
-
- ) : null}
-
- setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
- setLibTVImportOpen(false)} onApply={applyLibTVImport} />
- setTapNowImportOpen(false)} onApply={applyTapNowImport} />
-
- setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
-
- setPrevisTemplateRequest(null)} onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)} />
-
-
-
-
+
+ {!focusMode ? (
+ setTitleEditing(false)}
+ canUndo={historyState.canUndo}
+ canRedo={historyState.canRedo}
+ onCreateProject={createAndOpenProject}
+ onDeleteProject={deleteCurrentProject}
+ onSave={() => void saveCanvasProject()}
+ onImportImage={() => handleUploadRequest()}
+ onImportLibTV={() => setLibTVImportOpen(true)}
+ onImportTapNow={() => setTapNowImportOpen(true)}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onShare={() => setShareModalOpen(true)}
+ shortcutRequestNonce={shortcutRequestNonce}
+ syncIcon={}
+ onOpenSearch={() => setNodeSearchOpen(true)}
+ projectContext={
+ shortDramaEnabled && currentProject?.projectId
+ ? {
+ ...canvasContext,
+ projectId: currentProject.projectId,
+ projectName: linkedProjectQuery.data?.project.name || currentProject.title,
+ }
+ : undefined
}
- onViewportChange={handleViewportChange}
- onViewportPreviewChange={handleViewportPreviewChange}
- onCanvasMouseDown={handleCanvasMouseDown}
- boxSelectEnabled={canvasTool === "box-select"}
- onCanvasDoubleClick={handleCanvasDoubleClick}
- onCanvasDeselect={deselectCanvas}
- onContextMenu={handleCanvasContextMenu}
- onDrop={handleDrop}
- onFileDragEnter={handleFileDragEnter}
- onFileDragLeave={handleFileDragLeave}
- onFileDragOver={handleFileDragOver}
- >
-
-
-
+ ) : null}
+
+ setNodeSearchOpen(false)}
+ onFocus={(nodeId) => {
+ const target = nodeById.get(nodeId);
+ const parent = target?.parentId ? nodeById.get(target.parentId) : null;
+ if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
+ const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
+ if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
+ const selection = new Set([nodeId]);
+ selectedNodeIdsRef.current = selection;
+ setSelectedNodeIds(selection);
+ setSelectedConnectionId(null);
+ focusCanvasNode(nodeId);
+ }}
+ />
+
+ {!focusMode && shortDramaGuide ? (
+
+ ) : null}
+
+ setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
+ setLibTVImportOpen(false)} onApply={applyLibTVImport} />
+ setTapNowImportOpen(false)} onApply={applyTapNowImport} />
+
+ setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
+
+ setPrevisTemplateRequest(null)} onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)} />
+
+
+
+ {
- setSelectedConnectionId(connectionId);
- setSelectedNodeIds(new Set());
- setContextMenu(null);
- }}
- onConnectionContextMenu={(event, connectionId) => {
- setSelectedConnectionId(connectionId);
- setSelectedNodeIds(new Set());
- closeConnectionCreateMenu();
- setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId });
- }}
- onNodeMouseDown={handleNodeMouseDown}
- onNodeHoverStart={handleCanvasNodeHoverStart}
- onNodeHoverEnd={handleCanvasNodeHoverEnd}
- onConnectStart={handleConnectStart}
- onNodeResize={handleNodeResize}
- onToggleFrame={handleFrameToggle}
- onFolderStyleChange={handleFolderStyleChange}
- onFolderThemeChange={handleFolderThemeChange}
- onNodeTitleChange={handleNodeTitleChange}
- onNodeContextMenu={handleNodeContextMenu}
- onNodeContentChange={handleNodeContentChange}
- onToggleBatch={toggleBatchExpanded}
- onSetBatchPrimary={setBatchPrimary}
- onRetry={retryCanvasNode}
- onReloadResource={reloadCanvasNodeResource}
- onOpenTaskDetails={openCanvasNodeTaskDetails}
- onOpenVersions={openCanvasNodeVersions}
- onViewImage={viewCanvasNodeImage}
- onReplaceMedia={replaceCanvasNodeMedia}
- onOpenTextEditor={openTextNodeEditor}
- onOpenPrevis={editCanvasPrevis}
- onOpenDrawing={openDrawingNode}
- onStartBatchConnection={startBatchConnection}
+ alignmentGuides={alignmentGuides}
/>
-
-
-
+ }
+ onViewportChange={handleViewportChange}
+ onViewportPreviewChange={handleViewportPreviewChange}
+ onCanvasMouseDown={handleCanvasMouseDown}
+ boxSelectEnabled={canvasTool === "box-select"}
+ onCanvasDoubleClick={handleCanvasDoubleClick}
+ onCanvasDeselect={deselectCanvas}
+ onContextMenu={handleCanvasContextMenu}
+ onDrop={handleDrop}
+ onFileDragEnter={handleFileDragEnter}
+ onFileDragLeave={handleFileDragLeave}
+ onFileDragOver={handleFileDragOver}
+ >
+
+
+ {
+ setSelectedConnectionId(connectionId);
+ setSelectedNodeIds(new Set());
+ setContextMenu(null);
+ }}
+ onConnectionContextMenu={(event, connectionId) => {
+ setSelectedConnectionId(connectionId);
+ setSelectedNodeIds(new Set());
+ closeConnectionCreateMenu();
+ setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId });
+ }}
+ onNodeMouseDown={handleNodeMouseDown}
+ onNodeHoverStart={handleCanvasNodeHoverStart}
+ onNodeHoverEnd={handleCanvasNodeHoverEnd}
+ onConnectStart={handleConnectStart}
+ onNodeResize={handleNodeResize}
+ onToggleFrame={handleFrameToggle}
+ onFolderStyleChange={handleFolderStyleChange}
+ onFolderThemeChange={handleFolderThemeChange}
+ onNodeTitleChange={handleNodeTitleChange}
+ onNodeContextMenu={handleNodeContextMenu}
+ onNodeContentChange={handleNodeContentChange}
+ onToggleBatch={toggleBatchExpanded}
+ onSetBatchPrimary={setBatchPrimary}
+ onRetry={retryCanvasNode}
+ onReloadResource={reloadCanvasNodeResource}
+ onOpenTaskDetails={openCanvasNodeTaskDetails}
+ onViewImage={viewCanvasNodeImage}
+ onReplaceMedia={replaceCanvasNodeMedia}
+ onOpenTextEditor={openTextNodeEditor}
+ onOpenPrevis={editCanvasPrevis}
+ onOpenDrawing={openDrawingNode}
+ onStartBatchConnection={startBatchConnection}
+ />
+
+
+
-
+
- {focusMode ? (
- }
- versionsOpen={versions.open}
- onToggleVersions={() => { closeAgent(); versions.toggle(); }}
- dockRevealed={focusDockRevealed}
- zoomPercent={viewport.k}
- onToggleDock={() => setFocusDockRevealed((value) => !value)}
- onExit={exitFocusMode}
- onZoomIn={zoomCanvasIn}
- onZoomOut={zoomCanvasOut}
- onFit={fitCanvasContent}
- />
- ) : null}
+ {focusMode ? (
+ }
+ dockRevealed={focusDockRevealed}
+ zoomPercent={viewport.k}
+ onToggleDock={() => setFocusDockRevealed((value) => !value)}
+ onExit={exitFocusMode}
+ onZoomIn={zoomCanvasIn}
+ onZoomOut={zoomCanvasOut}
+ onFit={fitCanvasContent}
+ />
+ ) : null}
-
+
- {emptyCanvasState}
+ {emptyCanvasState}
- {!focusMode || focusDockRevealed ? (
- createNode(CanvasNodeType.Image)}
- onAddVideo={() => createNode(CanvasNodeType.Video)}
- onAddAudio={() => createNode(CanvasNodeType.Audio)}
- onAddText={() => createNode(CanvasNodeType.Text)}
- onChooseStyle={() => setStylePickerOpen(true)}
- onAddScript={() => createNode(CanvasNodeType.Script)}
- onAddFrame={() => createNode(CanvasNodeType.Frame)}
- onAddFolder={createFolder}
- onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
- onAddExtensionNode={(type) => createNode(type)}
- onAddWorkflow={() => createNode(CanvasNodeType.Config)}
- onOpenPrevis={() => setPrevisTemplateRequest({})}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onUpload={() => handleUploadRequest()}
- onDelete={() => deleteNodes(new Set(selectedNodeIds))}
- onClear={() => setClearConfirmOpen(true)}
- onDeselect={deselectCanvas}
- onAppearanceChange={applyCanvasAppearance}
- onSaveAppearanceDefault={saveCanvasAppearanceDefault}
- onBackgroundModeChange={setBackgroundMode}
- onShowImageInfoChange={setShowImageInfo}
- onOpenWorkspace={() => setWorkspaceOpen(value => !value)}
- onOpenMyAssets={() => {
- openCanvasAssetLibrary();
+ {!focusMode || focusDockRevealed ? (
+ createNode(CanvasNodeType.Image)}
+ onAddVideo={() => createNode(CanvasNodeType.Video)}
+ onAddAudio={() => createNode(CanvasNodeType.Audio)}
+ onAddText={() => createNode(CanvasNodeType.Text)}
+ onChooseStyle={() => setStylePickerOpen(true)}
+ onAddScript={() => createNode(CanvasNodeType.Script)}
+ onAddFrame={() => createNode(CanvasNodeType.Frame)}
+ onAddFolder={createFolder}
+ onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
+ onAddExtensionNode={(type) => createNode(type)}
+ onAddWorkflow={() => createNode(CanvasNodeType.Config)}
+ onOpenPrevis={() => setPrevisTemplateRequest({})}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onUpload={() => handleUploadRequest()}
+ onDelete={() => deleteNodes(new Set(selectedNodeIds))}
+ onClear={() => setClearConfirmOpen(true)}
+ onDeselect={deselectCanvas}
+ onAppearanceChange={applyCanvasAppearance}
+ onSaveAppearanceDefault={saveCanvasAppearanceDefault}
+ onBackgroundModeChange={setBackgroundMode}
+ onShowImageInfoChange={setShowImageInfo}
+ onOpenWorkspace={() => setWorkspaceOpen((value) => !value)}
+ onOpenMyAssets={() => {
+ openCanvasAssetLibrary();
+ }}
+ onOpenProjectCharacters={() => openCharacterLibrary()}
+ />
+ ) : null}
+
+
+
+ {
+ const currentNodes = nodesRef.current;
+ const target = currentNodes.find((node) => node.id === nodeId);
+ if (!target) {
+ message.info("该节点已删除或尚未同步到画布");
+ return;
+ }
+
+ const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null;
+ if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
+
+ const batchRootId = target.metadata?.batchRootId;
+ const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null;
+ if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
+
+ focusCanvasNode(nodeId);
}}
- onOpenProjectCharacters={() => openCharacterLibrary()}
/>
- ) : null}
+
-
- {
- const currentNodes = nodesRef.current;
- const target = currentNodes.find((node) => node.id === nodeId);
- if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }
-
- const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null;
- if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
-
- const batchRootId = target.metadata?.batchRootId;
- const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null;
- if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
-
- focusCanvasNode(nodeId);
- }} />
-
-
-
- {angleNode?.metadata?.content ? (
-
- setAngleNodeId(null)}
- onConfirm={(params) => {
- void generateAngleNode(angleNode, params);
- }}
- />
-
- ) : null}
-
- {lightingNode?.metadata?.content ? (
-
setLightingNodeId(null)}>
- setLightingNodeId(null)}
- onConfirm={(options, prompt) => {
- generateLightingNode(lightingNode, options, prompt);
- }}
- />
-
- ) : null}
-
- {emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
-
setEmotionNodeId(null)}
- onConfirm={(payload: CanvasImageEmotionPayload) => {
- void generateEmotionNode(emotionNode, payload);
- }}
- />
- ) : null}
-
- {dialogNode &&
- !isCanvasImageSourceNode(dialogNode) &&
- !dialogNode.metadata?.fileUpload &&
- dialogNode.type !== CanvasNodeType.Script &&
- dialogNode.type !== CanvasNodeType.BatchTable &&
- dialogNode.type !== CanvasNodeType.Drawing &&
- dialogNode.type !== CanvasNodeType.Panorama &&
- !(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) &&
- !dialogNode.metadata?.previsSceneId &&
- !selectionBox &&
- !isCanvasNodeMoving ? (
-
- {renderCanvasNodePanel(dialogNode)}
-
- ) : null}
-
- {pendingConnectionCreate ? (
- getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
- onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
- onClose={cancelPendingConnectionCreate}
- />
- ) : null}
-
- {connectionReplaceHover ? (
-
-
-
松开替换
-
@{connectionReplaceHover.referenceLabel}
-
- ) : null}
-
- {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
- beginBatchConnectionMode(Array.from(selectedNodeIds))}
- onMergeVideos={() => void mergeSelectedVideos()}
- onSendSelectionToAgent={() => sendSelectionToAgent()}
- />
- ) : null}
-
- {uploadStatus ? : null}
- {mergeVideoProgress ? : null}
- {lastAgentChange ? (
- {
- undoAgentOps();
- }}
- onClose={dismissLastAgentChange}
- />
- ) : null}
-
- (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))}
- onEditText={openTextNodeEditor}
- onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
- onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
- onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
- onGenerateImage={generateImageFromTextNode}
- onUpload={(node) => handleUploadRequest(node.id)}
- onDownload={downloadNodeImage}
- onSaveAsset={(node) => void saveNodeAsset(node)}
- onAnnotate={(node) => setAnnotationNodeId(node.id)}
- onAnnotationEdit={openAnnotationEditNode}
- onMaskEdit={(node) => setMaskEditNodeId(node.id)}
- onRemoveBackground={openBackgroundRemoval}
- onLayerDecomposition={openLayerDecomposition}
- onTextEdit={openTextEditNode}
- onEmotion={(node) => {
- setDialogNodeId(null);
- setEmotionNodeId((current) => (current === node.id ? null : node.id));
- }}
- onPortraitTexture={openPortraitTextureEditor}
- onCrop={(node) => setCropNodeId(node.id)}
- onSplit={(node, params) => void splitImageNode(node, params)}
- onUpscale={(node) => setUpscaleNodeId(node.id)}
- onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
- onAngle={(node) => {
- setDialogNodeId(null);
- setAngleNodeId((current) => (current === node.id ? null : node.id));
- }}
- onLighting={(node) => {
- setDialogNodeId(null);
- setLightingNodeId((current) => (current === node.id ? null : node.id));
- }}
- onPanorama={openPanoramaConfig}
- onPrevis={sendImageToPrevisAgent}
- onViewImage={(node) => setPreviewNodeId(node.id)}
- onExtractVideoFrames={openVideoFrameExtractor}
- onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
- onTrimVideoSegments={openVideoSegmentExtractor}
- onSubtitles={(node) => setSubtitleNodeId(node.id)}
- onTimeline={(node) => setTimelineNodeId(node.id)}
- extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
- extractingAudio={segmentRunningMode === "audio"}
- trimmingVideo={segmentRunningMode === "video"}
- onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)}
- onReversePrompt={createImageReversePromptNodes}
- onRetry={retryCanvasNode}
- onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
- onToggleLocked={(node) => toggleNodeLocked(node.id)}
- onDelete={(node) => deleteNodes(new Set([node.id]))}
- />
-
-{isMiniMapOpen && !focusMode ? : null}
-
- {!focusMode ? (
- event.stopPropagation()}
- onPointerDown={(event) => event.stopPropagation()}
- onWheel={(event) => event.stopPropagation()}
- >
- setIsMiniMapOpen((value) => !value)}
- onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
+ panelWidth={640}
+ panelHeight={540}
+ allowOverflow
+ dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
+ isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))}
+ >
+ setAngleNodeId(null)}
+ onConfirm={(params) => {
+ void generateAngleNode(angleNode, params);
+ }}
+ />
+
+ ) : null}
+
+ {lightingNode?.metadata?.content ? (
+ setLightingNodeId(null)}>
+ setLightingNodeId(null)}
+ onConfirm={(options, prompt) => {
+ generateLightingNode(lightingNode, options, prompt);
+ }}
+ />
+
+ ) : null}
+
+ {emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
+ setEmotionNodeId(null)}
+ onConfirm={(payload: CanvasImageEmotionPayload) => {
+ void generateEmotionNode(emotionNode, payload);
+ }}
/>
-
- ) : null}
+ ) : null}
- setContextMenu(null)}
- onAddNode={(type, position) => createNode(type, position)}
- onAddFolder={createFolder}
- onChooseStyle={() => setStylePickerOpen(true)}
- onOpenPrevis={(position) => setPrevisTemplateRequest({ position })}
- onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
- onOpenAssets={openCanvasAssetLibrary}
- onOpenProjectCharacters={(position) => openCharacterLibrary(position)}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onPaste={pasteAtPosition}
- onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
- onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
- onDuplicate={duplicateNode}
- onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
- onDeleteConnection={deleteConnection}
- onSaveAsset={(node) => {
- void saveNodeAsset(node);
- }}
- onViewMedia={(node) => setPreviewNodeId(node.id)}
- onEditText={openTextNodeEditor}
- onOpenDrawing={openDrawingNode}
- onGenerateImage={generateImageFromTextNode}
- onCopyContent={(node) => {
- void copyNodeContentToClipboard(node);
- }}
- onCopyMediaUrl={(node) => {
- void copyNodeMediaUrlToClipboard(node);
- }}
- onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
- onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
- onToggleFrame={(node) => handleFrameToggle(node.id)}
- onSpreadSelection={spreadSelectedNodes}
- onCopySelection={copySelectedNodes}
- onDeleteSelection={() => deleteNodes(selectedNodeIds)}
- onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
- />
+ {dialogNode &&
+ !isCanvasImageSourceNode(dialogNode) &&
+ !dialogNode.metadata?.fileUpload &&
+ dialogNode.type !== CanvasNodeType.Script &&
+ dialogNode.type !== CanvasNodeType.BatchTable &&
+ dialogNode.type !== CanvasNodeType.Drawing &&
+ dialogNode.type !== CanvasNodeType.Panorama &&
+ !(dialogNode.metadata?.workflowKind === "character" && dialogNode.metadata.characterAssetId) &&
+ !dialogNode.metadata?.previsSceneId &&
+ !selectionBox &&
+ !isCanvasNodeMoving ? (
+
+ {renderCanvasNodePanel(dialogNode)}
+
+ ) : null}
-
+ {pendingConnectionCreate ? (
+ getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
+ onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
+ onClose={cancelPendingConnectionCreate}
+ />
+ ) : null}
-
+ {connectionReplaceHover ? (
+
+
+
松开替换
+
@{connectionReplaceHover.referenceLabel}
+
+ ) : null}
- setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
+ {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
+ beginBatchConnectionMode(Array.from(selectedNodeIds))}
+ onMergeVideos={() => void mergeSelectedVideos()}
+ onSendSelectionToAgent={() => sendSelectionToAgent()}
+ />
+ ) : null}
- {subtitleNode ? (
- setSubtitleNodeId(null)}
- onSave={(nodeId, patch) => {
- handleConfigNodeChange(nodeId, patch);
- const currentTimeline = currentProject?.timeline;
- if (currentTimeline) {
- const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []);
- if (next !== currentTimeline) updateProject(projectId, { timeline: next });
+ {uploadStatus ? : null}
+ {mergeVideoProgress ? : null}
+ {lastAgentChange ? (
+ {
+ undoAgentOps();
+ }}
+ onClose={dismissLastAgentChange}
+ />
+ ) : null}
+
+ (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))}
+ onEditText={openTextNodeEditor}
+ onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
+ onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
+ onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
+ onGenerateImage={generateImageFromTextNode}
+ onUpload={(node) => handleUploadRequest(node.id)}
+ onDownload={downloadNodeImage}
+ onSaveAsset={(node) => void saveNodeAsset(node)}
+ onAnnotate={(node) => setAnnotationNodeId(node.id)}
+ onAnnotationEdit={openAnnotationEditNode}
+ onMaskEdit={(node) => setMaskEditNodeId(node.id)}
+ onRemoveBackground={openBackgroundRemoval}
+ onLayerDecomposition={openLayerDecomposition}
+ onTextEdit={openTextEditNode}
+ onEmotion={(node) => {
+ setDialogNodeId(null);
+ setEmotionNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onPortraitTexture={openPortraitTextureEditor}
+ onCrop={(node) => setCropNodeId(node.id)}
+ onSplit={(node, params) => void splitImageNode(node, params)}
+ onUpscale={(node) => setUpscaleNodeId(node.id)}
+ onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
+ onAngle={(node) => {
+ setDialogNodeId(null);
+ setAngleNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onLighting={(node) => {
+ setDialogNodeId(null);
+ setLightingNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onPanorama={openPanoramaConfig}
+ onPrevis={sendImageToPrevisAgent}
+ onViewImage={(node) => setPreviewNodeId(node.id)}
+ onExtractVideoFrames={openVideoFrameExtractor}
+ onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
+ onTrimVideoSegments={openVideoSegmentExtractor}
+ onSubtitles={(node) => setSubtitleNodeId(node.id)}
+ onTimeline={(node) => setTimelineNodeId(node.id)}
+ extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
+ extractingAudio={segmentRunningMode === "audio"}
+ trimmingVideo={segmentRunningMode === "video"}
+ onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)}
+ onReversePrompt={createImageReversePromptNodes}
+ onRetry={retryCanvasNode}
+ onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
+ onToggleLocked={(node) => toggleNodeLocked(node.id)}
+ onDelete={(node) => deleteNodes(new Set([node.id]))}
+ />
+
+ {isMiniMapOpen && !focusMode ? : null}
+
+ {!focusMode ? (
+ event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ onWheel={(event) => event.stopPropagation()}
+ >
+ setIsMiniMapOpen((value) => !value)}
+ onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
+ />
+
+ ) : null}
+
+ setContextMenu(null)}
+ onAddNode={(type, position) => createNode(type, position)}
+ onAddFolder={createFolder}
+ onChooseStyle={() => setStylePickerOpen(true)}
+ onOpenPrevis={(position) => setPrevisTemplateRequest({ position })}
+ onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
+ onOpenAssets={openCanvasAssetLibrary}
+ onOpenProjectCharacters={(position) => openCharacterLibrary(position)}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onPaste={pasteAtPosition}
+ onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
+ onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
+ onDuplicate={duplicateNode}
+ onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
+ onDeleteConnection={deleteConnection}
+ onSaveAsset={(node) => {
+ void saveNodeAsset(node);
+ }}
+ onViewMedia={(node) => setPreviewNodeId(node.id)}
+ onEditText={openTextNodeEditor}
+ onOpenDrawing={openDrawingNode}
+ onGenerateImage={generateImageFromTextNode}
+ onCopyContent={(node) => {
+ void copyNodeContentToClipboard(node);
+ }}
+ onCopyMediaUrl={(node) => {
+ void copyNodeMediaUrlToClipboard(node);
+ }}
+ onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
+ onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
+ onToggleFrame={(node) => handleFrameToggle(node.id)}
+ onSpreadSelection={spreadSelectedNodes}
+ onCopySelection={copySelectedNodes}
+ onDeleteSelection={() => deleteNodes(selectedNodeIds)}
+ onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
+ />
+
+
+
+
+
+ setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
+
+ {subtitleNode ? (
+ setSubtitleNodeId(null)}
+ onSave={(nodeId, patch) => {
+ handleConfigNodeChange(nodeId, patch);
+ const currentTimeline = currentProject?.timeline;
+ if (currentTimeline) {
+ const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []);
+ if (next !== currentTimeline) updateProject(projectId, { timeline: next });
+ }
+ }}
+ />
+ ) : null}
+
+ {frameNode ? void extractVideoFrames(frameNode, params)} /> : null}
+
+ {segmentNode && segmentDialogMode ? (
+ void handleSegmentConfirm(segmentNode, params)}
+ />
+ ) : null}
+
+ {timelineNode ? (
+ setTimelineNodeId(null)}
+ onOpenSubtitleDialog={(subNodeId) => {
+ setTimelineNodeId(null);
+ setSubtitleNodeId(subNodeId);
+ }}
+ onSave={(next) => updateProject(projectId, { timeline: next })}
+ onSaveSubtitles={(subNodeId, entries) =>
+ handleConfigNodeChange(subNodeId, {
+ subtitleEntries: entries,
+ ...(entries.length ? {} : { subtitleHighlights: [] }),
+ subtitleUpdatedAt: new Date().toISOString(),
+ })
+ }
+ onOpenAssetLibrary={openTimelineAssetLibrary}
+ onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")}
+ onUploadLocalFiles={uploadTimelineMedia}
+ addNodeToTimelineRef={timelineAddNodeRef}
+ addMediaToTimelineRef={timelineMediaAddRef}
+ onCreateAssembledNode={createVideoNodeFromBlob}
+ />
+ ) : null}
+
+ setCharacterReferenceNodeId(null)}
+ onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))}
+ />
+
+ setTextEditorNodeId(null)}
+ onSave={(nodeId, title, content, richText) => {
+ setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node)));
+ }}
+ />
+
+ {drawingNode ? (
+
+
+
+ }
+ >
+ setDrawingNodeId(null)}
+ onSaved={(nodeId, summary) => {
+ setNodes((current) =>
+ current.map((node) =>
+ node.id === nodeId
+ ? {
+ ...node,
+ metadata: {
+ ...node.metadata,
+ drawingEngine: summary.engine,
+ drawingRevision: summary.revision,
+ drawingUpdatedAt: summary.updatedAt,
+ drawingShapeCount: summary.shapeCount,
+ drawingPageCount: summary.pageCount,
+ },
+ }
+ : node,
+ ),
+ );
+ message.success("绘图已保存");
+ }}
+ />
+
+ ) : null}
+
+ {
+ artCritiqueRunningRef.current = running;
+ }}
+ node={artCritiqueNode}
+ upstreamNodes={artCritiqueInputs}
+ open={Boolean(artCritiqueNode)}
+ onClose={() => setArtCritiqueNodeId(null)}
+ onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
+ />
+
+ setPanoramaConfigNodeId(null)}
+ onConfirm={(composedPrompt, config) => {
+ const node = nodes.find((n) => n.id === panoramaConfigNodeId);
+ if (node) {
+ createPanoramaViewerWithConfig(node, composedPrompt, config);
}
}}
- />
- ) : null}
-
- {frameNode ? void extractVideoFrames(frameNode, params)} /> : null}
-
- {segmentNode && segmentDialogMode ? (
- void handleSegmentConfirm(segmentNode, params)}
- />
- ) : null}
-
- {timelineNode ? (
- setTimelineNodeId(null)}
- onOpenSubtitleDialog={(subNodeId) => {
- setTimelineNodeId(null);
- setSubtitleNodeId(subNodeId);
+ onCopyPrompt={(prompt) => {
+ void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
}}
- onSave={(next) => updateProject(projectId, { timeline: next })}
- onSaveSubtitles={(subNodeId, entries) =>
- handleConfigNodeChange(subNodeId, {
- subtitleEntries: entries,
- ...(entries.length ? {} : { subtitleHighlights: [] }),
- subtitleUpdatedAt: new Date().toISOString(),
- })
- }
- onOpenAssetLibrary={openTimelineAssetLibrary}
- onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")}
- onUploadLocalFiles={uploadTimelineMedia}
- addNodeToTimelineRef={timelineAddNodeRef}
- addMediaToTimelineRef={timelineMediaAddRef}
- onCreateAssembledNode={createVideoNodeFromBlob}
+ previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined}
+ nodes={nodes}
/>
- ) : null}
- setCharacterReferenceNodeId(null)} onUpdated={(detail) => setNodes((current) => refreshCanvasCharacterReferenceNodes(current, [{ ...detail.asset, character: detail.character }]))} />
+ setScriptEditorNodeId(null)}
+ onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)}
+ onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => {
+ if (!activeScriptNode || !visibleColumns.length) return;
+ setNodes((prev) =>
+ prev.map((node) =>
+ node.id === activeScriptNode.id
+ ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } }
+ : node,
+ ),
+ );
+ }}
+ onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
+ onGenerateVideos={(rowIds) => {
+ if (!activeScriptNode) return;
+ if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
+ else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
+ }}
+ onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
+ />
- setTextEditorNodeId(null)}
- onSave={(nodeId, title, content, richText) => {
- setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node)));
- }}
- />
+ {previsNodeId && activePrevisScene ? (
+
+
+
+ }
+ >
+ node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey))}
+ onClose={() => setPrevisNodeId(null)}
+ onChange={savePrevisScene}
+ onApply={applyPrevisOutput}
+ onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
+ onFlush={() => flushCanvasStorePersistence()}
+ onboardingScope={previsOnboardingScope}
+ />
+
+ ) : null}
- {drawingNode ? (
-
-
-
- }
- >
-
setDrawingNodeId(null)}
- onSaved={(nodeId, summary) => {
- setNodes((current) =>
- current.map((node) =>
- node.id === nodeId
- ? {
- ...node,
- metadata: {
- ...node.metadata,
- drawingEngine: summary.engine,
- drawingRevision: summary.revision,
- drawingUpdatedAt: summary.updatedAt,
- drawingShapeCount: summary.shapeCount,
- drawingPageCount: summary.pageCount,
- },
- }
- : node,
- ),
- );
- message.success("绘图已保存");
- }}
- />
-
- ) : null}
+ setCropNodeId(null)}
+ onCloseAnnotation={() => setAnnotationNodeId(null)}
+ onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)}
+ onCloseMaskEdit={() => setMaskEditNodeId(null)}
+ onCloseImageEdit={() => {
+ setImageEditNodeId(null);
+ setImageEditPreset(null);
+ }}
+ onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)}
+ onCloseTextEdit={() => setTextEditNodeId(null)}
+ onCloseUpscale={() => setUpscaleNodeId(null)}
+ onCrop={(node, crop) => void cropImageNode(node, crop)}
+ onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
+ onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)}
+ onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
+ onImageOperation={(node, payload) => void editImageNode(node, payload)}
+ onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)}
+ onDetectText={() => {
+ const node = textEditNodeId ? nodeById.get(textEditNodeId) : null;
+ return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在"));
+ }}
+ onTextEdit={(node, payload) => void editTextImageNode(node, payload)}
+ onUpscale={(node, params) => void upscaleImageNode(node, params)}
+ config={effectiveConfig}
+ />
- {
- artCritiqueRunningRef.current = running;
- }}
- node={artCritiqueNode}
- upstreamNodes={artCritiqueInputs}
- open={Boolean(artCritiqueNode)}
- onClose={() => setArtCritiqueNodeId(null)}
- onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
- />
+ setTaskDetail(null)}
+ onCancelTask={cancelCanvasTask}
+ superResolveNode={superResolveNode}
+ onCloseSuperResolve={() => setSuperResolveNodeId(null)}
+ previewNode={previewNode}
+ onClosePreview={() => setPreviewNodeId(null)}
+ clearConfirmOpen={clearConfirmOpen}
+ onCancelClear={() => setClearConfirmOpen(false)}
+ onConfirmClear={clearCanvas}
+ />
- setPanoramaConfigNodeId(null)}
- onConfirm={(composedPrompt, config) => {
- const node = nodes.find((n) => n.id === panoramaConfigNodeId);
- if (node) {
- createPanoramaViewerWithConfig(node, composedPrompt, config);
- }
- }}
- onCopyPrompt={(prompt) => {
- void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
- }}
- previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined}
- nodes={nodes}
- />
+
- setScriptEditorNodeId(null)}
- onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)}
- onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => {
- if (!activeScriptNode || !visibleColumns.length) return;
- setNodes((prev) =>
- prev.map((node) =>
- node.id === activeScriptNode.id
- ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } }
- : node,
- ),
- );
- }}
- onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
- onGenerateVideos={(rowIds) => {
- if (!activeScriptNode) return;
- if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
- else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
- }}
- onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
- />
-
- {previsNodeId && activePrevisScene ? (
-
-
-
- }
- >
- node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey))}
- onClose={() => setPrevisNodeId(null)}
- onChange={savePrevisScene}
- onApply={applyPrevisOutput}
- onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
- onFlush={() => flushCanvasStorePersistence()}
- onboardingScope={previsOnboardingScope}
- />
-
- ) : null}
-
- setVersionCompareRootId(null)}
- onSetPrimary={setPrimaryVersion}
- onFocus={(nodeId) => {
- setVersionCompareRootId(null);
- focusCanvasNode(nodeId);
- }}
- />
-
- setCropNodeId(null)}
- onCloseAnnotation={() => setAnnotationNodeId(null)}
- onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)}
- onCloseMaskEdit={() => setMaskEditNodeId(null)}
- onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }}
- onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)}
- onCloseTextEdit={() => setTextEditNodeId(null)}
- onCloseUpscale={() => setUpscaleNodeId(null)}
- onCrop={(node, crop) => void cropImageNode(node, crop)}
- onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
- onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)}
- onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
- onImageOperation={(node, payload) => void editImageNode(node, payload)}
- onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)}
- onDetectText={() => {
- const node = textEditNodeId ? nodeById.get(textEditNodeId) : null;
- return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在"));
- }}
- onTextEdit={(node, payload) => void editTextImageNode(node, payload)}
- onUpscale={(node, params) => void upscaleImageNode(node, params)}
- config={effectiveConfig}
- />
-
- setTaskDetail(null)}
- onCancelTask={cancelCanvasTask}
- superResolveNode={superResolveNode}
- onCloseSuperResolve={() => setSuperResolveNodeId(null)}
- previewNode={previewNode}
- onClosePreview={() => setPreviewNodeId(null)}
- clearConfirmOpen={clearConfirmOpen}
- onCancelClear={() => setClearConfirmOpen(false)}
- onConfirmClear={clearCanvas}
- />
-
-
-
-
- setCharacterLibraryOpen(false)}
- onInsert={async (payloads) => {
- await handleProjectAssetsInsert(payloads, characterLibraryPosition);
- }}
- />
-
-
- {versions.preview ? : null}
+
+ setCharacterLibraryOpen(false)}
+ onInsert={async (payloads) => {
+ await handleProjectAssetsInsert(payloads, characterLibraryPosition);
+ }}
+ />
+
+
-
>
);
diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts
index e5625c844..58f39a071 100644
--- a/web/src/pages/canvas/use-canvas-keyboard.ts
+++ b/web/src/pages/canvas/use-canvas-keyboard.ts
@@ -15,7 +15,7 @@ type UseCanvasKeyboardOptions = {
setCropNodeId: Dispatch>;
setMaskEditNodeId: Dispatch>;
setAnnotationNodeId: Dispatch>;
- saveCanvasProject: () => unknown;
+ saveCanvasProject: (options?: { requireRemote?: boolean }) => unknown;
zoomToActualSize: () => void;
fitCanvasContent: () => void;
fitCanvasSelection: () => void;
@@ -113,7 +113,7 @@ export function useCanvasKeyboard({
if (isModifierShortcut && !event.altKey && key === "s") {
event.preventDefault();
event.stopPropagation();
- if (!event.repeat) void saveCanvasProject();
+ if (!event.repeat) void saveCanvasProject({ requireRemote: false });
return;
}
if (isModifierShortcut && !event.altKey && key === "f") {
diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
index 18bf343f0..64926924d 100644
--- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts
+++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
@@ -9,11 +9,10 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
-import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
+import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
-import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -94,9 +93,6 @@ export function useCanvasProjectLifecycle({
const viewportSaveTimerRef = useRef | null>(null);
const observedContentRef = useRef(null);
- const loadLatestRef = useRef(false);
- const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
- const pendingReloadRef = useRef<{ promise: Promise; resolve: () => void; reject: (error: unknown) => void } | null>(null);
const editorReadyRef = useRef(false);
useEffect(() => {
@@ -105,10 +101,7 @@ export function useCanvasProjectLifecycle({
// Keep load intent on the refs until this attempt finishes. React Strict
// Mode remounts the effect; consuming the flags here would turn "load
// latest" into a normal open and immediately recreate the conflict.
- const latest = loadLatestRef.current;
- const historyRestore = historyRestoreRef.current;
- const pendingReload = pendingReloadRef.current;
- const keepEditor = editorReadyRef.current && (latest || Boolean(historyRestore));
+ const keepEditor = editorReadyRef.current;
if (!keepEditor) {
editorReadyRef.current = false;
setProjectLoaded(false);
@@ -156,16 +149,12 @@ export function useCanvasProjectLifecycle({
const load = async () => {
const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
- if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) {
+ if (cachedProject && cachedProject.nodes?.length) {
// 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
applyRestoredProject(cachedProject);
}
- const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
+ const loadedProject = await loadCanvasProjectForEditing(projectId, { onLoad: applyRestoredProject });
if (cancelled) return;
- if (historyRestoreRef.current === historyRestore) {
- historyRestoreRef.current = null;
- historyRestore?.resolve();
- }
if (!loadedProject) {
if (!cachedProject) navigate("/canvas", { replace: true });
return;
@@ -188,24 +177,10 @@ export function useCanvasProjectLifecycle({
void load()
.then(() => {
if (cancelled) return;
- loadLatestRef.current = false;
- if (pendingReloadRef.current === pendingReload) {
- pendingReloadRef.current = null;
- pendingReload?.resolve();
- }
})
.catch((error) => {
if (cancelled) return;
- loadLatestRef.current = false;
- if (historyRestoreRef.current === historyRestore) {
- historyRestoreRef.current = null;
- historyRestore?.reject(error);
- }
- if (pendingReloadRef.current === pendingReload) {
- pendingReloadRef.current = null;
- pendingReload?.reject(error);
- }
- if (useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
+ useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
const detail = error instanceof Error ? error.message : "读取画布失败,请重试";
if (keepEditor) message.error(detail);
else setLoadError(detail);
@@ -289,11 +264,8 @@ export function useCanvasProjectLifecycle({
}, [message, navigate]);
const deleteCurrentProject = useCallback(async () => {
- let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
+ const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
try {
- const drafts = await readCanvasSyncDrafts(projectId);
- const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : [])));
- drawingIds = drawingIds.filter((id) => !preserved.has(id));
await deleteCanvasProjectsWithRemoteSync([projectId]);
} catch (error) {
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
@@ -335,41 +307,6 @@ export function useCanvasProjectLifecycle({
if (options.flush !== false) await flushCanvasStorePersistence();
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
- const reloadLatestCanvasProject = useCallback(async () => {
- const pending = pendingReloadRef.current;
- if (pending) return pending.promise;
-
- // The load path writes a durable draft before replacing the editor. Waiting
- // for the ordinary canvas queue here makes a viewport drag able to starve
- // the cloud read, so only bring the latest editor snapshot into memory.
- useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "reconciling", message: "正在加载云端最新版本" });
- try {
- await persistLocalEdits({ flush: false });
- } catch (error) {
- useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "准备加载云端版本失败" });
- throw error;
- }
- let resolve!: () => void;
- let reject!: (error: unknown) => void;
- const promise = new Promise((done, fail) => {
- resolve = done;
- reject = fail;
- });
- pendingReloadRef.current = { promise, resolve, reject };
- loadLatestRef.current = true;
- setLoadAttempt((value) => value + 1);
- return promise;
- }, [persistLocalEdits, projectId]);
-
- const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
- await persistLocalEdits();
- return new Promise((resolve, reject) => {
- historyRestoreRef.current?.reject(new Error("已有新的恢复请求"));
- historyRestoreRef.current = { snapshotId, revision, resolve, reject };
- setLoadAttempt((value) => value + 1);
- });
- }, [persistLocalEdits]);
-
const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise => {
try {
await persistLocalEdits();
@@ -390,18 +327,6 @@ export function useCanvasProjectLifecycle({
return true;
}, [message, persistLocalEdits, projectId]);
- const forceSaveCanvasProject = useCallback(async (): Promise => {
- try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
- try {
- const result = await forceOverwriteRemoteCanvasSync(projectId);
- message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
- } catch (error) {
- message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
- return false;
- }
- return true;
- }, [message, persistLocalEdits]);
-
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
}, [cleanupCanvasFiles, projectId]);
@@ -416,10 +341,7 @@ export function useCanvasProjectLifecycle({
currentProject,
deleteCurrentProject,
renameCurrentProject,
- reloadLatestCanvasProject,
- restoreCanvasProjectVersion,
saveCanvasProject,
- forceSaveCanvasProject,
updateProject,
};
}
diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts
index 14c76dc55..80c1a0f68 100644
--- a/web/src/pages/canvas/use-canvas-render-model.ts
+++ b/web/src/pages/canvas/use-canvas-render-model.ts
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
-import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
+import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS } from "@/lib/canvas/canvas-render-budget";
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasConnectionIndex, type CanvasIndexedConnection } from "@/lib/canvas/canvas-connection-index";
@@ -15,7 +15,7 @@ import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/c
import type { Skill } from "@/services/api/skills";
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
import type { PrevisScene } from "@/types/previs";
-import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
+import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set } | null;
@@ -25,7 +25,6 @@ type UseCanvasRenderModelOptions = {
assets: Asset[];
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
- mediaPerformanceMode: CanvasMediaPerformanceMode;
selectedNodeIds: Set;
hoveredNodeId: string | null;
dragPreview: DragPreview;
@@ -45,7 +44,6 @@ type UseCanvasRenderModelOptions = {
emotionNodeId: string | null;
previewNodeId: string | null;
contextMenu: ContextMenuState | null;
- versionCompareRootId: string | null;
previsNodeId: string | null;
scriptEditorNodeId: string | null;
dialogNodeId: string | null;
@@ -57,7 +55,6 @@ export function useCanvasRenderModel({
assets,
viewport,
viewportSize,
- mediaPerformanceMode,
selectedNodeIds,
hoveredNodeId,
dragPreview,
@@ -77,12 +74,10 @@ export function useCanvasRenderModel({
emotionNodeId,
previewNodeId,
contextMenu,
- versionCompareRootId,
previsNodeId,
scriptEditorNodeId,
dialogNodeId,
}: UseCanvasRenderModelOptions) {
- const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
// These maps are consumed by both virtualization and node chrome. Building
// them together keeps a metadata update from walking a 50k-node array six
@@ -143,16 +138,16 @@ export function useCanvasRenderModel({
}, [collapsingBatchIds, nodeById, nodes]);
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
const connectionLayerBounds = useMemo(() => {
- const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
+ const padding = 144 / Math.max(viewport.k, 0.05);
const left = -viewport.x / viewport.k - padding;
const top = -viewport.y / viewport.k - padding;
const width = viewportSize.width / viewport.k + padding * 2;
const height = viewportSize.height / viewport.k + padding * 2;
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
- }, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
+ }, [viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const renderBounds = useMemo(() => {
- const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
- const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
+ const enterPadding = canvasNodeRenderPadding(false) / viewport.k;
+ const retainPadding = canvasNodeRenderPadding(true) / viewport.k;
const viewLeft = -viewport.x / viewport.k;
const viewTop = -viewport.y / viewport.k;
const viewWidth = viewportSize.width / viewport.k;
@@ -162,7 +157,7 @@ export function useCanvasRenderModel({
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
};
- }, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
+ }, [viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const layoutNodesRef = useRef(nodes);
const layoutNodes = useMemo(() => {
const retained = retainCanvasLayoutNodes(layoutNodesRef.current, nodes);
@@ -268,10 +263,6 @@ export function useCanvasRenderModel({
if (!positionOnlyChange) semanticNodesRef.current = nodes;
return semanticNodesRef.current;
}, [nodes]);
- const versionCompareNodes = useMemo(() => {
- if (!versionCompareRootId) return [];
- return nodes.filter((node) => (node.metadata?.versionOfNodeId || node.id) === versionCompareRootId).sort((a, b) => (a.metadata?.versionLabel || "").localeCompare(b.metadata?.versionLabel || ""));
- }, [nodes, versionCompareRootId]);
const selectedNodeIdForToolbar = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const toolbarCandidate = selectedNodeIdForToolbar ? nodeById.get(selectedNodeIdForToolbar) || null : null;
@@ -440,7 +431,6 @@ export function useCanvasRenderModel({
nodeById,
nodeRenderLODById,
previewNode,
- reduceMediaEffects,
relatedHighlight,
resourceReferenceByNodeId,
selectedNodeBounds,
@@ -451,7 +441,6 @@ export function useCanvasRenderModel({
superResolveNode,
toolbarNode,
upscaleNode,
- versionCompareNodes,
visibleNodes,
};
}
diff --git a/web/src/pages/canvas/use-canvas-storyboard.ts b/web/src/pages/canvas/use-canvas-storyboard.ts
index ba32ddd22..bad99a868 100644
--- a/web/src/pages/canvas/use-canvas-storyboard.ts
+++ b/web/src/pages/canvas/use-canvas-storyboard.ts
@@ -17,6 +17,7 @@ import {
expandStoryboardTextMentions,
storyboardRowsFromTask,
storyboardPromptTemplateMetadata,
+ stripProjectStyleExecutionBlocks,
withStoryboardOutputContract,
} from "@/lib/canvas/canvas-project-domain";
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
@@ -131,7 +132,11 @@ export function useCanvasStoryboard({
const generateScriptRows = useCallback(async (nodeId: string, prompt: string, callerSignal?: AbortSignal) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
- if (!scriptNode || !prompt.trim()) return;
+ const cleanedPrompt = stripProjectStyleExecutionBlocks(prompt);
+ if (!scriptNode || !cleanedPrompt) {
+ if (scriptNode && !cleanedPrompt) message.warning("分镜脚本缺少剧情内容,无法生成");
+ return;
+ }
if (storyboardRequests.current.has(nodeId) || scriptNode.metadata?.status === NODE_STATUS_LOADING) { message.warning("当前分镜正在处理,请等待原任务完成"); return false; }
const lifetimeSignal = storyboardLifetime.current.signal;
const signal = callerSignal ? AbortSignal.any([lifetimeSignal, callerSignal]) : lifetimeSignal;
@@ -153,7 +158,7 @@ export function useCanvasStoryboard({
const shotDurationSeconds = shotDuration === "auto" ? 0 : Number(shotDuration);
const shotCount = scriptNode.metadata?.storyboardShotCount || "auto";
const requestedShotCount = shotCount === "auto" ? 0 : Number(shotCount);
- const expandedPrompt = expandStoryboardTextMentions(prompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
+ const expandedPrompt = expandStoryboardTextMentions(cleanedPrompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
const generationConfig = buildGenerationConfig(effectiveConfig, scriptNode, "text");
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
@@ -167,7 +172,7 @@ export function useCanvasStoryboard({
skills: addedSkills,
});
assertCurrent();
- setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
+ setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: cleanedPrompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
assertCurrent();
// 输入框内容只是剧情,拆镜要求与镜头数、每镜时长都必须随契约一起发出去。
const storyboardPrompt = withStoryboardOutputContract(skillExecution.prompt, { shotCount: requestedShotCount, shotDurationSeconds });
@@ -183,7 +188,6 @@ export function useCanvasStoryboard({
prompt: storyboardPrompt,
canvasAssets: buildStoryboardAssetCatalog(nodesRef.current),
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
- projectStyle: storyboardContext.projectStyle,
characters: storyboardContext.characters,
shotDurationSeconds,
shotCount: requestedShotCount,
diff --git a/web/src/pages/projects/detail/editor.tsx b/web/src/pages/projects/detail/editor.tsx
index 9a8962e67..7d5469f3e 100644
--- a/web/src/pages/projects/detail/editor.tsx
+++ b/web/src/pages/projects/detail/editor.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import "@/lib/plugins/builtin/editor/editor-shell";
import {
AlertTriangle,
Check,
diff --git a/web/src/pages/settings/model-default-grid.tsx b/web/src/pages/settings/model-default-grid.tsx
index 5a7ce572c..a7f008045 100644
--- a/web/src/pages/settings/model-default-grid.tsx
+++ b/web/src/pages/settings/model-default-grid.tsx
@@ -68,8 +68,8 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
{modelDisplayName(config, model)}
-
- {channel.name || "未命名渠道"}
+
+ {channel.scope === "system" ? "系统模型" : channel.name || "未命名渠道"}
{channel.scope === "system" ? "系统" : "自定义"}
{creditsEnabled && cost ? {formatPrice(cost.billingMode === "token" ? (cost.outputTokenPriceMicrocredits || 0) : cost.unitPriceMicrocredits)} /{cost.billingMode === "token" ? "百万 Token" : cost.billingMode === "per_second" ? "秒" : "次"} : null}
diff --git a/web/src/services/agent-canvas-sync.ts b/web/src/services/agent-canvas-sync.ts
index 40c12898c..124cdb377 100644
--- a/web/src/services/agent-canvas-sync.ts
+++ b/web/src/services/agent-canvas-sync.ts
@@ -14,15 +14,17 @@ type Options = {
export function createAgentCanvasSync(options: Options) {
let patches: AgentCanvasPatch[] = [];
let needsRefresh = false;
- let supportsPatches = false;
let disposed = false;
let running = false;
let lastRefresh = -Infinity;
let timer: ReturnType | undefined;
const schedule = () => {
if (disposed || running || timer || (!patches.length && !needsRefresh)) return;
- const wait = patches.length ? options.batchMs ?? 40 : Math.max(options.batchMs ?? 40, (options.refreshIntervalMs ?? 1000) - (Date.now() - lastRefresh));
- timer = setTimeout(() => { timer = undefined; void flush(); }, wait);
+ const wait = patches.length ? (options.batchMs ?? 40) : Math.max(options.batchMs ?? 40, (options.refreshIntervalMs ?? 1000) - (Date.now() - lastRefresh));
+ timer = setTimeout(() => {
+ timer = undefined;
+ void flush();
+ }, wait);
};
const flush = async () => {
if (disposed || running) return;
@@ -31,8 +33,11 @@ export function createAgentCanvasSync(options: Options) {
patches = [];
try {
if (batch.length) {
- try { await options.applyPatches(batch); }
- catch { needsRefresh = true; }
+ try {
+ await options.applyPatches(batch);
+ } catch {
+ needsRefresh = true;
+ }
}
if (!disposed && needsRefresh && Date.now() - lastRefresh >= (options.refreshIntervalMs ?? 1000)) {
needsRefresh = false;
@@ -51,14 +56,33 @@ export function createAgentCanvasSync(options: Options) {
if (disposed || (event.payload.canvasId && event.payload.canvasId !== options.canvasId)) return;
const patch = event.payload.canvasPatch as AgentCanvasPatch | undefined;
if (patch && patch.canvasId === options.canvasId) {
- supportsPatches = true;
patches.push(patch);
- } else if (event.type === "canvas_updated" || event.type === "canvas_undone" || (!supportsPatches && (event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))))) {
+ } else if (
+ !patches.length &&
+ (event.type === "canvas_updated" ||
+ event.type === "canvas_undone" ||
+ event.type === "generation_task_created" ||
+ event.type === "tool_failed" ||
+ event.type === "run_failed" ||
+ (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName))))
+ ) {
+ // A patch may be dropped even after earlier patches succeeded.
+ // Keep the snapshot fallback available for every meaningful task
+ // boundary; refreshes remain rate-limited below.
needsRefresh = true;
}
schedule();
},
- reconcile() { if (!disposed) { needsRefresh = true; schedule(); } },
- dispose() { disposed = true; if (timer) clearTimeout(timer); patches = []; },
+ reconcile() {
+ if (!disposed) {
+ needsRefresh = true;
+ schedule();
+ }
+ },
+ dispose() {
+ disposed = true;
+ if (timer) clearTimeout(timer);
+ patches = [];
+ },
};
}
diff --git a/web/src/services/api/agent.ts b/web/src/services/api/agent.ts
index 5b8a34ed7..f6f292ba1 100644
--- a/web/src/services/api/agent.ts
+++ b/web/src/services/api/agent.ts
@@ -9,6 +9,10 @@ export type AgentMediaSettings = {
channelModelKey?: string;
size: string;
quality: string;
+ durationSeconds?: number;
+ videoGenerateAudio?: boolean;
+ videoWatermark?: boolean;
+ videoArkPrivateAssetUpload?: boolean;
};
export type AgentProfileScope = "user" | "project" | "canvas";
diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts
index 3a713b135..db0751489 100644
--- a/web/src/services/api/resources.ts
+++ b/web/src/services/api/resources.ts
@@ -127,8 +127,24 @@ export type ResourceAccessFetchOptions = {
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
+type ResourceAccessRequestPayload = {
+ resourceId: string;
+ purpose: ResourceAccessPurpose;
+ variant: ResourceAccessVariant;
+ downloadName?: string;
+};
+type PendingResourceAccess = {
+ key: string;
+ scope: string;
+ generation: number;
+ payload: ResourceAccessRequestPayload;
+ resolve: (value: ResourceAccess) => void;
+ reject: (reason?: unknown) => void;
+};
const accessCache = new Map();
const accessRequests = new Map>();
+const pendingResourceAccess = new Map();
+let resourceAccessBatchTimer: ReturnType | undefined;
const PERSISTED_DISPLAY_ACCESS_PREFIX = "resource-access-v1:";
function persistedDisplayAccessKey(resourceId: string, variant: ResourceAccessVariant) {
@@ -184,6 +200,13 @@ let accessGeneration = 0;
*/
export function clearResourceAccessCache() {
accessGeneration += 1;
+ if (resourceAccessBatchTimer !== undefined) {
+ clearTimeout(resourceAccessBatchTimer);
+ resourceAccessBatchTimer = undefined;
+ }
+ const abortError = new DOMException("资源访问请求已因账号切换失效", "AbortError");
+ pendingResourceAccess.forEach((entry) => entry.reject(abortError));
+ pendingResourceAccess.clear();
accessCache.clear();
accessRequests.clear();
resourceCache.clear();
@@ -378,6 +401,81 @@ function assertResourceRequestCurrent(generation: number, scope: string) {
}
}
+const RESOURCE_ACCESS_BATCH_LIMIT = 100;
+const RESOURCE_ACCESS_BATCH_DELAY_MS = 16;
+
+function resourceAccessAbortError() {
+ return new DOMException("资源访问请求已因账号切换失效", "AbortError");
+}
+
+function scheduleResourceAccessBatch() {
+ if (resourceAccessBatchTimer !== undefined) return;
+ resourceAccessBatchTimer = setTimeout(() => {
+ resourceAccessBatchTimer = undefined;
+ void flushResourceAccessBatch();
+ }, RESOURCE_ACCESS_BATCH_DELAY_MS);
+}
+
+function enqueueResourceAccess(entry: Omit) {
+ return new Promise((resolve, reject) => {
+ pendingResourceAccess.set(entry.key, { ...entry, resolve, reject });
+ scheduleResourceAccessBatch();
+ });
+}
+
+async function completeResourceAccessBatchEntry(entry: PendingResourceAccess, item: ResourceAccessBatchItem | undefined) {
+ if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) throw resourceAccessAbortError();
+ if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
+ const value = item.access;
+ const cachedEntry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
+ if (cachedEntry.expiresAt > Date.now()) {
+ accessCache.set(entry.key, cachedEntry);
+ if (entry.payload.purpose === "display") await persistDisplayAccess(entry.scope, entry.payload.resourceId, entry.payload.variant, cachedEntry);
+ }
+ if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) {
+ accessCache.delete(entry.key);
+ throw resourceAccessAbortError();
+ }
+ return value;
+}
+
+async function flushResourceAccessBatch() {
+ const currentScope = getActiveUserScope();
+ const currentGeneration = accessGeneration;
+ const queuedEntries = Array.from(pendingResourceAccess.values());
+ pendingResourceAccess.clear();
+ const entries: PendingResourceAccess[] = [];
+ for (const entry of queuedEntries) {
+ if (entry.scope !== currentScope || entry.generation !== currentGeneration) entry.reject(resourceAccessAbortError());
+ else if (entries.length < RESOURCE_ACCESS_BATCH_LIMIT) entries.push(entry);
+ else pendingResourceAccess.set(entry.key, entry);
+ }
+ if (!entries.length) {
+ if (pendingResourceAccess.size) scheduleResourceAccessBatch();
+ return;
+ }
+
+ try {
+ const data = await http.post<{ items: ResourceAccessBatchItem[] }>(
+ "/resources/access",
+ entries.map((entry) => entry.payload),
+ );
+ await Promise.all(
+ entries.map(async (entry, index) => {
+ try {
+ entry.resolve(await completeResourceAccessBatchEntry(entry, data.items?.[index]));
+ } catch (error) {
+ entry.reject(error);
+ }
+ }),
+ );
+ } catch (error) {
+ entries.forEach((entry) => entry.reject(error));
+ } finally {
+ if (pendingResourceAccess.size) scheduleResourceAccessBatch();
+ }
+}
+
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "", fetchOptions: ResourceAccessFetchOptions = {}) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
@@ -413,19 +511,12 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
return persisted.value;
}
}
- const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
- if (generation !== accessGeneration || scope !== getActiveUserScope()) {
- throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
- }
- const item = data.items?.[0];
- if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
- const value = item.access;
- const entry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
- if (entry.expiresAt > Date.now()) {
- accessCache.set(key, entry);
- if (purpose === "display") await persistDisplayAccess(scope, id, variant, entry);
- }
- return value;
+ return await enqueueResourceAccess({
+ key,
+ scope,
+ generation,
+ payload: { resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) },
+ });
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
throw error;
diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts
index e89abb119..dc9e812c4 100644
--- a/web/src/services/api/user-data.ts
+++ b/web/src/services/api/user-data.ts
@@ -41,6 +41,22 @@ export type CanvasLibrarySummary = Pick }
+ | { type: "create_node"; node: CanvasProject["nodes"][number] }
+ | { type: "patch_node"; id: string; patch: Record; before?: Record }
+ | { type: "remove_node"; id: string; expected?: CanvasProject["nodes"][number] }
+ | { type: "create_connection"; connection: CanvasProject["connections"][number] }
+ | { type: "update_connection"; id: string; connection: CanvasProject["connections"][number]; expected: CanvasProject["connections"][number] }
+ | { type: "remove_connection"; id: string; expected?: CanvasProject["connections"][number] }
+ | { type: "set_field"; field: "title" | "projectId" | "chatSessions" | "activeChatId" | "starterMode" | "appearance" | "backgroundMode" | "showImageInfo" | "previsScenes" | "timeline"; value: unknown; beforeValue: unknown };
+
+export type CanvasOperationsRequest = {
+ mutationId: string;
+ baseRevision?: number;
+ operations: CanvasOperation[];
+};
+
export function listRemoteCanvasProjectsPage(options: { page: number; pageSize: number; projectId?: string; query?: string; sort?: string; signal?: AbortSignal }) {
return http.get<{ projects: CanvasLibrarySummary[]; page: number; pageSize: number; total: number; hasMore: boolean }>("/canvas-projects", {
signal: options.signal,
@@ -130,36 +146,10 @@ export function getRemoteCanvasProject(id: string, knownProject?: CanvasProject)
.then((result) => ("notModified" in result ? { project: knownProject, notModified: true } : { ...result, notModified: false }));
}
-export function upsertRemoteCanvasProject(project: CanvasProject, options?: { repairMissingResources?: boolean }) {
- const { viewport: _viewport, remoteContentHash: _hash, ...content } = project;
- return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content, ...(options?.repairMissingResources ? { repairMissingResources: true } : {}) });
+export function applyRemoteCanvasOperations(id: string, request: CanvasOperationsRequest) {
+ return http.patch<{ project: RemoteUserDataSummary & { revision: number }; mutationId: string }>(`/canvas-projects/${encodeURIComponent(id)}/operations`, request);
}
export function deleteRemoteCanvasProject(id: string) {
return http.delete<{ id: string }>(`/canvas-projects/${encodeURIComponent(id)}`);
}
-
-export type CanvasHistoryEntry = {
- id: string;
- canvasId: string;
- revision: number;
- title: string;
- nodeCount: number;
- connectionCount: number;
- payloadBytes: number;
- reason: "automatic" | "before_restore";
- createdAt: string;
- contentUpdatedAt: string;
-};
-
-export function listCanvasHistory(id: string, signal?: AbortSignal) {
- return http.get<{ snapshots: CanvasHistoryEntry[]; currentRevision: number }>(`/canvas-projects/${encodeURIComponent(id)}/history`, { signal });
-}
-
-export function getCanvasHistoryEntry(id: string, snapshotId: string, signal?: AbortSignal) {
- return http.get<{ snapshot: CanvasHistoryEntry; project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}`, { signal });
-}
-
-export function restoreRemoteCanvasHistory(id: string, snapshotId: string, revision: number) {
- return http.post<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}/restore`, { revision });
-}
diff --git a/web/src/services/canvas-generation-consumer.ts b/web/src/services/canvas-generation-consumer.ts
index 59cc2cca3..893780286 100644
--- a/web/src/services/canvas-generation-consumer.ts
+++ b/web/src/services/canvas-generation-consumer.ts
@@ -207,7 +207,14 @@ function reconcileCinematicEntities(previous:
continue;
}
const previousEntity = previousById.get(liveEntity.id);
- if (!previousEntity) continue;
+ if (!previousEntity) {
+ // A generated entity may have been created after the previous
+ // snapshot. Keep the live branch visible so an ACK failure does not
+ // turn a recoverable local result into a silent deletion.
+ reconciled.push(liveEntity);
+ included.add(liveEntity.id);
+ continue;
+ }
reconciled.push(previousEntity);
included.add(previousEntity.id);
}
diff --git a/web/src/services/canvas-image-loader.ts b/web/src/services/canvas-image-loader.ts
index 3ea55d3fe..0e45ebf3e 100644
--- a/web/src/services/canvas-image-loader.ts
+++ b/web/src/services/canvas-image-loader.ts
@@ -1,5 +1,96 @@
+import { getActiveUserScope } from "@/lib/user-scope";
+
const MAX_ACTIVE_IMAGE_PREPARATIONS = 4;
const IMAGE_PREPARATION_TIMEOUT_MS = 60_000;
+const MAX_CACHED_IMAGES = 48;
+const MAX_CACHED_PIXELS = 16_000_000;
+const IMAGE_CACHE_TTL_MS = 60_000;
+
+type SharedPreparation = { controller: AbortController; promise: Promise; consumers: number };
+const preparations = new Map();
+const preparedImages = new Map();
+let preparedPixels = 0;
+let preparationScope = "";
+
+/** Share work across nodes and LOD remounts without letting one consumer cancel the others. */
+export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise {
+ if (signal.aborted) return Promise.reject(abortError());
+ const scope = getActiveUserScope();
+ if (scope !== preparationScope) {
+ preparationScope = scope;
+ preparedImages.clear();
+ preparedPixels = 0;
+ for (const pending of preparations.values()) pending.controller.abort();
+ preparations.clear();
+ }
+ for (const [key, value] of preparedImages) {
+ if (value.expiresAt <= Date.now()) {
+ preparedPixels -= value.pixels;
+ preparedImages.delete(key);
+ }
+ }
+ const cached = preparedImages.get(src);
+ if (cached) {
+ preparedImages.delete(src);
+ preparedImages.set(src, cached);
+ return Promise.resolve(cached.image);
+ }
+ let pending = preparations.get(src);
+ if (!pending) {
+ const controller = new AbortController();
+ const entry: SharedPreparation = { controller, consumers: 0, promise: enqueueCanvasImage(src, controller.signal, timeoutMs) };
+ entry.promise = entry.promise
+ .then((image) => {
+ if (controller.signal.aborted || scope !== getActiveUserScope() || preparations.get(src) !== entry) throw abortError();
+ const pixels = image.naturalWidth * image.naturalHeight;
+ if (pixels > 0 && pixels <= MAX_CACHED_PIXELS && !src.startsWith("blob:")) {
+ while (preparedImages.size >= MAX_CACHED_IMAGES || preparedPixels + pixels > MAX_CACHED_PIXELS) {
+ const oldest = preparedImages.keys().next().value;
+ if (oldest === undefined) break;
+ preparedPixels -= preparedImages.get(oldest)!.pixels;
+ preparedImages.delete(oldest);
+ }
+ preparedImages.set(src, { image, pixels, expiresAt: Date.now() + IMAGE_CACHE_TTL_MS });
+ preparedPixels += pixels;
+ }
+ return image;
+ })
+ .finally(() => {
+ if (preparations.get(src) === entry) preparations.delete(src);
+ });
+ pending = entry;
+ preparations.set(src, pending);
+ }
+ const shared = pending;
+ shared.consumers += 1;
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (image?: HTMLImageElement, error?: unknown) => {
+ if (settled) return;
+ settled = true;
+ signal.removeEventListener("abort", onAbort);
+ shared.consumers -= 1;
+ if (error !== undefined) reject(error);
+ else resolve(image!);
+ };
+ const onAbort = () => {
+ finish(undefined, abortError());
+ // React can immediately remount the same image. Allow that consumer
+ // to join before cancelling an otherwise unreferenced job.
+ queueMicrotask(() => {
+ if (shared.consumers === 0 && preparations.get(src) === shared) {
+ preparations.delete(src);
+ shared.controller.abort();
+ }
+ });
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ shared.promise.then(
+ (image) => finish(image),
+ (error) => finish(undefined, error),
+ );
+ });
+}
type ImagePreparationJob = {
src: string;
@@ -10,6 +101,7 @@ type ImagePreparationJob = {
settled: boolean;
image?: HTMLImageElement;
timeout?: ReturnType;
+ timeoutMs: number;
onAbort: () => void;
};
@@ -17,10 +109,10 @@ const queue: ImagePreparationJob[] = [];
let activePreparations = 0;
/** Load and decode one image under a shared concurrency budget. Queued work is cancellable. */
-export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise {
+function enqueueCanvasImage(src: string, signal: AbortSignal, timeoutMs: number): Promise {
if (signal.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
- const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, onAbort: () => {} };
+ const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, timeoutMs, onAbort: () => {} };
job.onAbort = () => {
if (job.settled) return;
if (!job.started) {
@@ -34,7 +126,7 @@ export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs =
};
signal.addEventListener("abort", job.onAbort, { once: true });
queue.push(job);
- pump(timeoutMs);
+ pump();
});
}
@@ -42,7 +134,7 @@ export function canvasImagePreparationStats() {
return { active: activePreparations, queued: queue.length, limit: MAX_ACTIVE_IMAGE_PREPARATIONS };
}
-function pump(timeoutMs: number) {
+function pump() {
while (activePreparations < MAX_ACTIVE_IMAGE_PREPARATIONS && queue.length) {
const job = queue.shift()!;
if (job.settled || job.signal.aborted) {
@@ -54,13 +146,14 @@ function pump(timeoutMs: number) {
job.timeout = setTimeout(() => {
job.image?.removeAttribute("src");
settle(job, undefined, new Error("图片加载或解码超时"));
- }, timeoutMs);
+ }, job.timeoutMs);
const image = new Image();
job.image = image;
image.decoding = "async";
image.onload = () => {
if (job.settled || job.signal.aborted) return;
+ image.onload = null;
void image.decode().then(
() => settle(job, image),
(error) => settle(job, undefined, error),
@@ -77,9 +170,13 @@ function settle(job: ImagePreparationJob, image?: HTMLImageElement, error?: unkn
job.settled = true;
job.signal.removeEventListener("abort", job.onAbort);
if (job.timeout) clearTimeout(job.timeout);
+ if (job.image) {
+ job.image.onload = null;
+ job.image.onerror = null;
+ }
if (job.started) {
activePreparations = Math.max(0, activePreparations - 1);
- pump(IMAGE_PREPARATION_TIMEOUT_MS);
+ pump();
}
if (error !== undefined) job.reject(error);
else if (image) job.resolve(image);
diff --git a/web/src/services/canvas-missing-resources.ts b/web/src/services/canvas-missing-resources.ts
new file mode 100644
index 000000000..da587e82a
--- /dev/null
+++ b/web/src/services/canvas-missing-resources.ts
@@ -0,0 +1,70 @@
+type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
+
+const RESOURCE_KEY = /^resource:([^/]+)$/;
+const RESOURCE_URL = /\/api\/resources\/([^/]+)\/file(?:[/?#]|$)/;
+const LOCATOR_KEY = /(?:storage|resource|preview|poster|thumbnail|content|url|locator|asset)/i;
+
+function resourceId(value: string) {
+ const storageMatch = value.match(RESOURCE_KEY);
+ if (storageMatch) return storageMatch[1];
+ return value.match(RESOURCE_URL)?.[1] || "";
+}
+
+function isMediaLocatorKey(key: string) {
+ return LOCATOR_KEY.test(key);
+}
+
+function collect(value: unknown, ids: Set, key = "") {
+ if (typeof value === "string") {
+ if (isMediaLocatorKey(key)) {
+ const id = resourceId(value);
+ if (id) ids.add(id);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((item) => collect(item, ids, key));
+ return;
+ }
+ if (!value || typeof value !== "object") return;
+ Object.entries(value).forEach(([childKey, childValue]) => collect(childValue, ids, childKey));
+}
+
+export function canvasResourceIds(value: unknown) {
+ const ids = new Set();
+ collect(value, ids);
+ return ids;
+}
+
+function clear(value: JsonValue, missing: Set, serverReported: boolean, key = ""): JsonValue | undefined {
+ if (Array.isArray(value)) return value.map((item) => clear(item, missing, serverReported, key)).filter((item): item is JsonValue => item !== undefined);
+ if (!value || typeof value !== "object") {
+ if (typeof value === "string" && isMediaLocatorKey(key) && missing.has(resourceId(value))) return undefined;
+ return value;
+ }
+
+ const entries = Object.entries(value);
+ const invalidMedia =
+ !serverReported &&
+ entries.some(([childKey, childValue]) => {
+ if (typeof childValue !== "string" || !isMediaLocatorKey(childKey)) return false;
+ return missing.has(resourceId(childValue));
+ });
+ const output: Record = {};
+ for (const [childKey, childValue] of entries) {
+ if (childKey === "assetId" && invalidMedia && !serverReported) continue;
+ if (serverReported && childKey !== "assetId" && isMediaLocatorKey(childKey)) continue;
+ if (typeof childValue === "string" && isMediaLocatorKey(childKey) && missing.has(resourceId(childValue))) continue;
+ if (childKey === "status" && childValue === "success" && invalidMedia) {
+ output[childKey] = "idle";
+ continue;
+ }
+ const next = clear(childValue, missing, serverReported, childKey);
+ if (next !== undefined) output[childKey] = next;
+ }
+ return output;
+}
+
+export function clearMissingCanvasResources(value: T, missingIds: Set, _scope = "", serverReported = false): T {
+ return clear(value as unknown as JsonValue, missingIds, serverReported) as T;
+}
diff --git a/web/src/services/canvas-sync-drafts.ts b/web/src/services/canvas-sync-drafts.ts
deleted file mode 100644
index a51217cdb..000000000
--- a/web/src/services/canvas-sync-drafts.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { nanoid } from "nanoid";
-import { localForageStorageForScope } from "@/lib/localforage-storage";
-import { getActiveUserScope } from "@/lib/user-scope";
-import { sameCanvasContent } from "@/lib/canvas/canvas-content";
-import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store";
-import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
-import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
-
-export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
-const draftKey = (projectId: string) => `yingce:sync-drafts:${projectId}`;
-const draftIndexKey = "yingce:sync-draft-index";
-
-export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) {
- const raw = await localForageStorageForScope(scope).getItem(draftIndexKey);
- const ids: string[] = raw ? JSON.parse(raw) : [];
- return (await Promise.all(ids.map((id) => readCanvasSyncDrafts(id, scope)))).flat();
-}
-
-export async function readCanvasSyncDrafts(projectId: string, scope = getActiveUserScope()): Promise {
- const raw = await localForageStorageForScope(scope).getItem(draftKey(projectId));
- return raw ? JSON.parse(raw) : [];
-}
-
-/** Caller must hold the scope's canvas persistence lock. Never reacquire it here. */
-export async function preserveCanvasSyncDraftLocked(snapshot: CanvasProject, scope: string) {
- try {
- const drafts = await readCanvasSyncDrafts(snapshot.id, scope);
- if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) {
- const storage = localForageStorageForScope(scope);
- const rawIndex = await storage.getItem(draftIndexKey);
- const ids: string[] = rawIndex ? JSON.parse(rawIndex) : [];
- if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id]));
- drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot });
- await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts));
- }
- if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(snapshot.id, { draftCount: drafts.length, draftError: undefined });
- return drafts.length;
- } catch (error) {
- if (getActiveUserScope() === scope) {
- const progress = useSyncProgressStore.getState();
- const current = progress.syncingProjects[snapshot.id];
- progress.setProjectProgress(snapshot.id, {
- phase: current?.phase === "conflict" ? "conflict" : "error",
- errorKind: "draft",
- draftError: error instanceof Error ? error.message : "本地草稿保存失败",
- message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变",
- });
- }
- throw error;
- }
-}
-
-export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) {
- const snapshot = structuredClone(project);
- return withGenerationArtifactCommitLock(scope, () => withCanvasStorePersistenceLock(scope, () => preserveCanvasSyncDraftLocked(snapshot, scope)));
-}
diff --git a/web/src/services/provider-neutral-generation-effects.ts b/web/src/services/provider-neutral-generation-effects.ts
index ecca11f29..f15f0e272 100644
--- a/web/src/services/provider-neutral-generation-effects.ts
+++ b/web/src/services/provider-neutral-generation-effects.ts
@@ -50,11 +50,11 @@ function effectStorage(scope: string): EffectStorage {
}
function effectLock(): AsyncLock {
- if (typeof window !== "undefined") {
- const locks = navigator.locks;
- if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
- return locks;
- }
+ // if (typeof window !== "undefined") {
+ // const locks = navigator.locks;
+ // if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
+ // return locks;
+ // }
return {
async request(name: string, callback: () => Promise) {
const prior = inProcessLockTails.get(name) ?? Promise.resolve();
diff --git a/web/src/services/user-data-sync-media.ts b/web/src/services/user-data-sync-media.ts
index 092b85050..5b67b5222 100644
--- a/web/src/services/user-data-sync-media.ts
+++ b/web/src/services/user-data-sync-media.ts
@@ -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()):
return [...set];
}
-export async function ensureRemoteResourceReferences(value: T, uploaded = new Map(), onUploaded?: () => void): Promise {
+/**
+ * 修复早期版本已经把文本正文保存成资源 URL 的画布数据。
+ * 上传文本时 prompt 与正文相同,优先使用 prompt,避免一次额外的资源下载;
+ * 其他旧数据再从同一 storageKey 读取原始文件。
+ */
+export async function restoreRemoteTextNodeContents(value: T, readText: (storageKey: string) => Promise = readRemoteTextResource): Promise {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
+ const record = value as Record;
+ 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;
+ 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) };
+ 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(value: T, uploaded = new Map(), onUploaded?: () => void, preserveTextContent = false): Promise {
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 = {};
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, storageKey: string) {
+export function applyResourceReference(payload: Record, 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;
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts
index 574cd6391..d9ff91dbe 100644
--- a/web/src/services/user-data-sync.ts
+++ b/web/src/services/user-data-sync.ts
@@ -1,21 +1,11 @@
import { canonicalize } from "json-canonicalize";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
-import {
- deleteRemoteAssets,
- deleteRemoteCanvasProject,
- getRemoteAsset,
- getRemoteAssetsByIds,
- getRemoteCanvasProject,
- getRemoteUserDataSnapshot,
- listRemoteAssetsPage,
- restoreRemoteCanvasHistory,
- upsertRemoteAsset,
- upsertRemoteCanvasProject,
-} from "@/services/api/user-data";
+import { deleteRemoteAssets, deleteRemoteCanvasProject, applyRemoteCanvasOperations, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset } from "@/services/api/user-data";
+import type { CanvasOperation, CanvasOperationsRequest } from "@/services/api/user-data";
import { ApiError } from "@/services/api/request";
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
import { getActiveUserScope } from "@/lib/user-scope";
-import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
+import { localForageStorageForScope } from "@/lib/localforage-storage";
import { appQueryClient } from "@/lib/query-client";
import { parseAssetRecordList } from "@/lib/asset-record";
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
@@ -25,10 +15,10 @@ 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 } 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";
export { numberValue } from "./user-data-sync-media";
@@ -44,22 +34,128 @@ let remoteOperationTail: Promise = Promise.resolve();
let subscriptionsInstalled = false;
let acknowledgedAssets = new Map();
let acknowledgedProjects = new Map();
+type PendingCanvasMutation = { projectId: string; request: CanvasOperationsRequest; baseline?: CanvasProject; prepared: CanvasProject; createdAt: string };
+const pendingCanvasMutations = new Map();
+const pendingCanvasMutationKey = "yingce:canvas-mutation-journal";
+let pendingJournalScope = "";
+let pendingJournalLoaded = false;
let incrementalSession = false;
let sessionEpoch = 0;
const verifiedProjects = new Set();
+const remoteDeletedProjects = new Set();
const verifiedAssets = new Set();
-type RemoteProjectLoad = { mode: "normal" | "latest" | "history"; promise: Promise };
+type RemoteProjectLoad = { mode: "normal"; promise: Promise };
const remoteProjectLoadPromises = new Map();
const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
+export function remoteUserDataRetryDelay(attempt: number, retryAfterMs = 0) {
+ const normalizedAttempt = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
+ const delay = REMOTE_SYNC_RETRY_DELAYS_MS[Math.min(normalizedAttempt, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1)];
+ return Math.max(delay, retryAfterMs);
+}
+
+async function loadPendingCanvasMutationJournal(scope = getActiveUserScope()) {
+ if (pendingJournalLoaded && pendingJournalScope === scope) return;
+ pendingJournalScope = scope;
+ pendingJournalLoaded = true;
+ pendingCanvasMutations.clear();
+ const raw = await localForageStorageForScope(scope).getItem(pendingCanvasMutationKey);
+ if (!raw) return;
+ try {
+ const entries = JSON.parse(raw) as PendingCanvasMutation[];
+ let repaired = false;
+ for (const entry of entries) {
+ if (!(entry?.projectId && entry.request?.mutationId && Array.isArray(entry.request.operations))) continue;
+ const request = repairPendingCanvasMutationRequest(entry.request, entry.baseline, entry.prepared);
+ repaired ||= request !== entry.request;
+ pendingCanvasMutations.set(entry.projectId, request === entry.request ? entry : { ...entry, request });
+ }
+ if (repaired) await persistPendingCanvasMutationJournal();
+ } catch {
+ await localForageStorageForScope(scope).removeItem(pendingCanvasMutationKey);
+ }
+}
+
+/**
+ * 修复旧版生成器写入 journal 的无效请求,这些请求服务端必然拒绝:
+ * 1. 新画布请求在 create_canvas.patch 之后又追加了同字段 set_field(beforeValue 缺失或为 null),
+ * 服务端先创建字段再校验基线,必然冲突;这些 set_field 与 patch 重复,直接移除。
+ * 2. 已有画布的 set_field 因 undefined 被 JSON 丢弃属性,补回 JSON null 还原原始意图。
+ * 3. 节点补丁的 undefined 被序列化丢弃,造成空 patch/before;只从 journal 原始快照恢复,不能使用当前云端值替代基线。
+ * 服务端校验失败会整笔回滚、不写 receipt,因此沿用原 mutationId 不会触发 canvas_mutation_id_reused。
+ */
+export function repairPendingCanvasMutationRequest(request: CanvasOperationsRequest, baseline?: CanvasProject, prepared?: CanvasProject): CanvasOperationsRequest {
+ let changed = false;
+ const create = request.operations.find((operation) => operation.type === "create_canvas");
+ const createdFields = new Set(create && create.type === "create_canvas" ? Object.keys(create.patch || {}) : []);
+ const kept = request.operations.filter((operation) => {
+ if (operation.type === "set_field" && operation.field === "projectId" && (operation.value === null || operation.value === undefined) && operation.beforeValue === "") {
+ // 空字符串和缺省 projectId 都表示未关联项目。旧版本把这次语义上的 no-op
+ // 记录成 set_field,服务端按字面字段比较时会误报冲突;移除它不会丢弃编辑。
+ changed = true;
+ return false;
+ }
+ if (!create || operation.type !== "set_field" || !createdFields.has(operation.field)) return true;
+ changed = true;
+ return false;
+ });
+ const operations = kept
+ .map((operation) => {
+ if (operation.type === "patch_node" && (!Object.keys(operation.patch).length || !Object.keys(operation.before || {}).length)) {
+ const beforeNode = baseline?.nodes.find((node) => node.id === operation.id);
+ const afterNode = prepared?.nodes.find((node) => node.id === operation.id);
+ // 空补丁或空基线必然被服务端拒绝且没有成功回执,沿用原 mutationId 安全重试。
+ // 缺少原快照时不猜测旧值;有效请求不重新生成,以免改变已确认请求的摘要。
+ if (beforeNode && afterNode) {
+ const diff = diffCanvasObject(beforeNode as unknown as Record, afterNode as unknown as Record);
+ changed = true;
+ if (!Object.keys(diff.patch).length) return undefined;
+ return { ...operation, patch: diff.patch, before: diff.previous };
+ }
+ }
+ if (operation.type !== "set_field") return operation;
+ const hasBefore = Object.prototype.hasOwnProperty.call(operation, "beforeValue") && operation.beforeValue !== undefined;
+ const hasValue = Object.prototype.hasOwnProperty.call(operation, "value") && operation.value !== undefined;
+ if (hasBefore && hasValue) return operation;
+ changed = true;
+ return {
+ ...operation,
+ value: hasValue ? operation.value : canvasOperationValue(prepared?.[operation.field]),
+ beforeValue: hasBefore ? operation.beforeValue : canvasOperationValue(baseline?.[operation.field]),
+ };
+ })
+ .filter((operation): operation is CanvasOperation => operation !== undefined);
+ return changed ? { ...request, operations } : request;
+}
+
+async function persistPendingCanvasMutationJournal() {
+ if (!pendingJournalScope) return;
+ const storage = localForageStorageForScope(pendingJournalScope);
+ if (!pendingCanvasMutations.size) {
+ await storage.removeItem(pendingCanvasMutationKey);
+ return;
+ }
+ await storage.setItem(pendingCanvasMutationKey, JSON.stringify([...pendingCanvasMutations.values()]));
+}
+
+function newCanvasMutationId() {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
export async function initializeRemoteUserDataSession(userId: string) {
await withRemoteUserDataSyncExclusive(async () => {
resetRemoteUserDataSync();
activeRemoteUserId = userId;
+ await loadPendingCanvasMutationJournal();
incrementalSession = true;
acknowledgedProjects = new Map(useCanvasStore.getState().projects.map((project) => [project.id, project]));
+ for (const pending of pendingCanvasMutations.values()) {
+ if (pending.baseline) acknowledgedProjects.set(pending.projectId, pending.baseline);
+ }
acknowledgedAssets = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
+ if (pendingCanvasMutations.size) scheduleRemoteUserDataSync();
});
}
@@ -70,16 +166,11 @@ function liveCanvasIfUnchanged(id: string, snapshot: CanvasProject | null | unde
return live && sameCanvasContent(live, snapshot) ? live : undefined;
}
-export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
- const mode = options.historyRestore ? "history" : options.latest ? "latest" : "normal";
+export async function loadCanvasProjectForEditing(id: string, options: { onLoad?: (project: CanvasProject) => void } = {}) {
+ const mode = "normal";
const existing = remoteProjectLoadPromises.get(id);
const existingRequest = existing?.promise;
if (existingRequest) {
- if (existing?.mode !== mode) {
- await existingRequest.catch(() => undefined);
- return loadCanvasProjectForEditing(id, options);
- }
- if (mode !== "normal") return existingRequest;
return existingRequest.then(async (loaded) => {
if (loaded) options.onLoad?.(loaded);
return loaded;
@@ -96,29 +187,10 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
if (live === undefined) throw new Error(message);
return live;
};
- if (options.historyRestore) {
- const local = useCanvasStore.getState().openProject(id);
- if (local) {
- const draftCount = await preserveCanvasSyncDraft(local, scope);
- useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
- }
- requireUnchangedLocal(local, "本地内容仍在更新,请稍后再恢复历史版本");
- const { snapshotId, revision } = options.historyRestore;
- try {
- const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
- if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果");
- if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容");
- } catch (error) {
- if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) {
- useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message });
- }
- throw error;
- }
- }
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);
@@ -136,27 +208,17 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
const cachedClean = local?.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local));
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
if (dirty && !sameCanvasContent(local, remote)) {
- const draftCount = await preserveCanvasSyncDraft(local, scope);
- const liveAfterDraft = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本");
- useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
- if (!options.latest && !options.historyRestore && local.revision !== undefined) {
- if (local.revision !== remote.revision) {
- useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" });
- } else {
- // A cached draft is not an acknowledged cloud save. Once its
- // ancestor is verified, retain it as pending against that content.
- acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
- verifiedProjects.add(id);
- useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
- scheduleRemoteUserDataSync();
- }
- options.onLoad?.(liveAfterDraft || local);
- return liveAfterDraft || local;
- }
+ const live = requireUnchangedLocal(local, "画布仍在更新,未确认操作已保留");
+ acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
+ verifiedProjects.add(id);
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", cloudRevision: remote.revision, pendingChanges: 1, message: "保留本地未确认操作,等待自动重放" });
+ options.onLoad?.(live || local);
+ scheduleRemoteUserDataSync();
+ return live || local;
}
// Editing/generation may continue during the network request or draft write.
// Viewport-only updates are not document edits and must not block adopting cloud content.
- const live = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本");
+ const live = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容");
const project = { ...remote, viewport: live?.viewport || local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
// Align live editor refs synchronously before publishing the new revision.
// A generation callback must never see old rendered nodes paired with a new revision.
@@ -165,7 +227,7 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] }));
await flushCanvasStorePersistence();
- useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已加载云端最新版本" });
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已确认云端画布基线" });
return project;
});
const pending = { mode, promise: request } as RemoteProjectLoad;
@@ -193,7 +255,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);
@@ -253,7 +315,7 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
let projected = current;
// A rejected delta requests a snapshot through createAgentCanvasSync.
// Only that reconciliation can distinguish stale replay from a conflict.
- if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== (await canvasContentHash(baseline))) throw new Error("本地缓存尚未核对,请加载云端最新版本");
+ if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== (await canvasContentHash(baseline))) throw new Error("本地画布基线尚未核对,等待自动校准");
if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
const orderedPatches: AgentCanvasPatch[] = [];
const seenRevisions = new Set();
@@ -290,9 +352,24 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
}
async function preserveAgentConflict(project: CanvasProject) {
- useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" });
- const draftCount = await preserveCanvasSyncDraft(project);
- useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount });
+ useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", pendingChanges: 1, message: "Agent 修改等待按原操作自动重放" });
+ scheduleRemoteUserDataSync();
+}
+
+function pendingMutationCreatesCanvas(mutation: PendingCanvasMutation | undefined) {
+ return mutation?.request.operations[0]?.type === "create_canvas";
+}
+
+export function mergeRemoteAssetsWithLocalPending(remoteAssets: Asset[], localAssets: Asset[]) {
+ const merged = new Map(remoteAssets.map((asset) => [asset.id, asset]));
+ for (const local of localAssets) {
+ const remote = merged.get(local.id);
+ // A local asset absent from the snapshot is an unacknowledged create.
+ // A same-ID difference is an unconfirmed edit. Keep both kinds visible;
+ // the normal save path will validate/rebase them against the server.
+ if (!remote || !sameEntitySnapshot(remote, local)) merged.set(local.id, local);
+ }
+ return [...merged.values()];
}
async function waitForRemoteProjectLoads() {
@@ -356,8 +433,9 @@ export async function syncRemoteUserData(userId?: string | null) {
await withRemoteUserDataSyncExclusive(async () => {
incrementalSession = false;
activeRemoteUserId = userId || "";
+ if (activeRemoteUserId) await loadPendingCanvasMutationJournal();
acknowledgedProjects.clear();
- acknowledgedAssets.clear();
+ acknowledgedAssets = new Map();
if (!activeRemoteUserId) {
remoteUserDataPhase = "inactive";
return;
@@ -368,29 +446,84 @@ export async function syncRemoteUserData(userId?: string | null) {
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
const snapshot = await getRemoteUserDataSnapshot();
const localProjects = useCanvasStore.getState().projects;
+ const localAssets = useAssetStore.getState().assets;
const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
- // Archive unsynced work before replacing the active cache, including deleted remote canvases.
+ // Convert cached local edits into the same durable operation journal before replacing the cache.
for (const local of localProjects) {
+ const pending = pendingCanvasMutations.get(local.id);
+ const remote = remoteById.get(local.id);
+ if (!remote) {
+ if (pending && !pendingMutationCreatesCanvas(pending)) {
+ remoteDeletedProjects.add(local.id);
+ useSyncProgressStore.getState().setProjectProgress(local.id, { phase: "error", errorKind: "conflict", pendingChanges: 1, message: "云端画布已删除,未确认操作不会重新创建它" });
+ continue;
+ }
+ if (!pending && (local.revision ?? 0) > 0) {
+ remoteDeletedProjects.add(local.id);
+ useSyncProgressStore.getState().setProjectProgress(local.id, { phase: "error", errorKind: "conflict", pendingChanges: 1, message: "云端画布已删除,未确认操作不会重新创建它" });
+ continue;
+ }
+ }
+ if (remote) remoteDeletedProjects.delete(local.id);
+ if (pending) continue;
const clean = local.remoteContentHash && local.remoteContentHash === (await canvasContentHash(local));
- if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
+ if (clean || sameCanvasContent(local, remote)) continue;
+ const operations = buildCanvasOperations(remote, local);
+ if (operations.length) {
+ pendingCanvasMutations.set(local.id, {
+ projectId: local.id,
+ request: { mutationId: newCanvasMutationId(), ...(remote ? { baseRevision: remote.revision } : {}), operations },
+ baseline: remote,
+ prepared: local,
+ createdAt: new Date().toISOString(),
+ });
+ }
}
- const projects = await Promise.all(
+ await persistPendingCanvasMutationJournal();
+ const remoteProjects = await Promise.all(
snapshot.projects.map(async (project) => {
const local = localProjects.find((item) => item.id === project.id);
- const draftCount = (await readCanvasSyncDrafts(project.id)).length;
- useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, draftCount, message: "已保存到云端" });
- return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
+ const remoteContentHash = await canvasContentHash(project);
+ const pending = pendingCanvasMutations.has(project.id);
+ useSyncProgressStore
+ .getState()
+ .setProjectProgress(project.id, { phase: pending ? "pending" : "done", cloudRevision: project.revision, pendingChanges: pending ? 1 : 0, message: pending ? "保留未确认画布操作,等待重放" : "已保存到云端" });
+ return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash };
}),
);
+ const orphanProjects = localProjects.filter((project) => remoteDeletedProjects.has(project.id)).map((project) => ({ ...project, remoteContentHash: project.remoteContentHash }));
+ // A revision-0 project can be absent from the cloud snapshot because its
+ // create mutation has not been acknowledged yet. Keep it visible so the
+ // pending journal can replay against the next session.
+ const pendingProjects = localProjects
+ .filter((project) => {
+ const pending = pendingCanvasMutations.get(project.id);
+ return Boolean(pending && pendingMutationCreatesCanvas(pending) && !remoteById.has(project.id));
+ })
+ .map((project) => ({ ...project, remoteContentHash: project.remoteContentHash }));
+ const projects = [
+ ...remoteProjects.map((project) => {
+ const pending = pendingCanvasMutations.get(project.id);
+ const local = localProjects.find((item) => item.id === project.id);
+ return pending && local ? { ...local, revision: project.revision, remoteContentHash: project.remoteContentHash } : project;
+ }),
+ ...orphanProjects,
+ ...pendingProjects.filter((project) => !remoteDeletedProjects.has(project.id)),
+ ];
+ const remoteAssets = parseAssetRecordList(snapshot.assets);
+ const assets = mergeRemoteAssetsWithLocalPending(remoteAssets, localAssets);
if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
useCanvasStore.getState().replaceProjects(projects);
- useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
+ useAssetStore.getState().replaceAssets(assets);
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
- acknowledgedProjects = new Map(projects.map((project) => [project.id, project]));
- acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset]));
+ acknowledgedProjects = new Map(remoteProjects.map((project) => [project.id, project]));
+ acknowledgedAssets = new Map(remoteAssets.map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
+ syncRetryAttempt = 0;
+ if (pendingCanvasMutations.size) scheduleRemoteUserDataSync();
} catch (error) {
remoteUserDataPhase = "failed";
+ scheduleRemoteUserDataHydrationRetry(error);
throw error;
}
});
@@ -409,7 +542,6 @@ export function installRemoteUserDataAutoSync() {
if (remoteUserDataPhase === "ready") {
for (const project of changed) {
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
- if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" });
}
}
@@ -424,12 +556,16 @@ export function resetRemoteUserDataSync() {
sessionEpoch += 1;
incrementalSession = false;
verifiedProjects.clear();
+ remoteDeletedProjects.clear();
verifiedAssets.clear();
remoteProjectLoadPromises.clear();
activeRemoteUserId = "";
remoteUserDataPhase = "inactive";
acknowledgedAssets.clear();
acknowledgedProjects.clear();
+ pendingCanvasMutations.clear();
+ pendingJournalScope = "";
+ pendingJournalLoaded = false;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
@@ -498,22 +634,10 @@ function updateRetryProgress(delayMs: number) {
}
}
-function stopRetryProgress() {
- const progress = useSyncProgressStore.getState();
- for (const item of Object.values(progress.syncingProjects)) {
- if (item.phase !== "error" || !item.message?.includes("自动重试")) continue;
- progress.setProjectProgress(item.projectId, { message: "云端同步失败,请点击立即重试" });
- }
-}
-
function scheduleRemoteUserDataRetry(error: unknown) {
if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "ready") return false;
- if (syncRetryAttempt >= REMOTE_SYNC_RETRY_DELAYS_MS.length) {
- stopRetryProgress();
- return false;
- }
- const delayMs = Math.max(REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt], error instanceof ApiError ? error.retryAfterMs || 0 : 0);
- syncRetryAttempt += 1;
+ const delayMs = remoteUserDataRetryDelay(syncRetryAttempt, error instanceof ApiError ? error.retryAfterMs || 0 : 0);
+ syncRetryAttempt = Math.min(syncRetryAttempt + 1, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1);
if (syncTimer) window.clearTimeout(syncTimer);
updateRetryProgress(delayMs);
syncTimer = window.setTimeout(() => {
@@ -523,23 +647,25 @@ function scheduleRemoteUserDataRetry(error: unknown) {
return true;
}
-/** 用户主动点击重试时重新开始退避窗口,不影响本地已保存的内容。 */
-export function retryRemoteUserDataSync(projectId?: string) {
- syncRetryAttempt = 0;
- if (syncTimer) {
- window.clearTimeout(syncTimer);
+function scheduleRemoteUserDataHydrationRetry(error: unknown) {
+ if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "failed") return false;
+ const delayMs = remoteUserDataRetryDelay(syncRetryAttempt, error instanceof ApiError ? error.retryAfterMs || 0 : 0);
+ syncRetryAttempt = Math.min(syncRetryAttempt + 1, REMOTE_SYNC_RETRY_DELAYS_MS.length - 1);
+ if (syncTimer) window.clearTimeout(syncTimer);
+ syncTimer = window.setTimeout(() => {
syncTimer = null;
- }
- return saveRemoteUserDataNow(projectId);
+ void syncRemoteUserData(activeRemoteUserId).catch((retryError) => console.warn("云端数据基线自动恢复失败", retryError));
+ }, delayMs);
+ return true;
}
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
if (error instanceof ApiError && error.reason === "canvas_history_resources_missing") {
- return `${localAction},云端同步已暂停:${detail}。请修复缺失素材或从有效历史版本恢复。`;
+ return `${localAction},云端同步已暂停:${detail}。资源补齐后会自动重放未确认操作。`;
}
if (error instanceof ApiError && (error.status === 409 || error.status === 428)) {
- return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`;
+ return `${localAction},云端同步已暂停:${detail}。未确认操作会保留并按原请求自动重放。`;
}
return `${localAction},云端同步失败:${detail}。将自动重试。`;
}
@@ -553,6 +679,7 @@ export function localSavedRemotePendingMessage(localAction: string, error: unkno
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) {
const id = useCanvasStore.getState().createProject(title, projectId);
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
+ remoteDeletedProjects.delete(id);
if (!activeRemoteUserId) return { id };
try {
await saveRemoteUserDataNow(id);
@@ -609,6 +736,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (activeRemoteUserId) {
await deleteRemoteCanvasProject(id);
acknowledgedProjects.delete(id);
+ remoteDeletedProjects.delete(id);
}
useCanvasStore.getState().deleteProjects([id]);
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
@@ -706,48 +834,27 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
});
}
-export async function saveRemoteUserDataNow(input?: string | readonly string[] | { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean }) {
- const projectId = typeof input === "string" || Array.isArray(input)
- ? (input as string | readonly string[])
- : input && typeof input === "object" && !Array.isArray(input)
- ? (input as { projectId?: string | readonly string[] }).projectId
- : undefined;
- const options = input && typeof input === "object" && !Array.isArray(input)
- ? (input as { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean })
- : {};
+export async function saveRemoteUserDataNow(input?: string | readonly string[]) {
+ const projectId = input;
const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地");
requireRemoteUserDataBaseline();
- const assertNoConflict = () => {
- if (options.force) return;
- const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId;
- if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
- throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
- }
- };
- if (projectId !== undefined) assertNoConflict();
await waitForRemoteProjectLoads();
if (syncPromise) {
syncQueued = true;
await syncPromise;
- 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.
- if (options.force || options.repairMissingResources) {
- return saveRemoteUserDataNow(input);
- }
+ if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
return;
}
syncPromise = withRemoteUserDataSyncExclusive(async () => {
requireRemoteUserDataBaseline();
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
- await drainRemoteUserDataChanges(options);
+ await drainRemoteUserDataChanges({ projectId });
});
let syncFailed = false;
let syncError: unknown;
try {
await syncPromise;
- assertNoConflict();
syncRetryAttempt = 0;
} catch (error) {
syncFailed = true;
@@ -762,45 +869,100 @@ 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 });
+function canvasOperationValueEqual(left: unknown, right: unknown) {
+ return canonicalize(left) === canonicalize(right);
}
-export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise {
- const epoch = sessionEpoch;
- if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试");
- requireRemoteUserDataBaseline();
- await waitForRemoteProjectLoads();
- const rebind = await withRemoteUserDataSyncExclusive(async () => {
- if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
- requireRemoteUserDataBaseline();
- const selectedProjectIds = projectId === undefined ? undefined : new Set([projectId]);
- await repairMissingCanvasVideoPreviews(selectedProjectIds);
- if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
- const projects = useCanvasStore.getState().projects;
- const claimedIds = [...collectCanvasMediaAssetIds(selectedProjectIds ? projects.filter((project) => selectedProjectIds.has(project.id)) : projects)];
- const remoteAssets: Asset[] = [];
- for (let offset = 0; offset < claimedIds.length; offset += 100) {
- const { assets } = await getRemoteAssetsByIds(claimedIds.slice(offset, offset + 100));
- remoteAssets.push(...assets);
+function diffCanvasObject(before: Record, after: Record, prefix = "") {
+ const patch: Record = {};
+ const previous: Record = {};
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
+ for (const key of keys) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ const hasBefore = Object.prototype.hasOwnProperty.call(before, key);
+ const hasAfter = Object.prototype.hasOwnProperty.call(after, key);
+ const oldValue = before[key];
+ const newValue = after[key];
+ if (hasBefore && hasAfter && oldValue && newValue && typeof oldValue === "object" && typeof newValue === "object" && !Array.isArray(oldValue) && !Array.isArray(newValue)) {
+ const nested = diffCanvasObject(oldValue as Record, newValue as Record, path);
+ Object.assign(patch, nested.patch);
+ Object.assign(previous, nested.previous);
+ continue;
}
- if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
- const remoteById = new Map(remoteAssets.map((asset) => [asset.id, asset]));
- 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;
- });
- await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
- return rebind;
+ // undefined 在对象 JSON 中会被丢弃,导致 before 或 patch 为空。
+ // 服务端用 null 表示不存在/删除;缺省字段与 undefined 之间不需要发送修改。
+ if (canvasOperationValueEqual(canvasOperationValue(oldValue), canvasOperationValue(newValue))) continue;
+ previous[path] = canvasOperationValue(oldValue);
+ patch[path] = canvasOperationValue(newValue);
+ }
+ return { patch, previous };
}
-async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
+function canvasOperationValue(value: unknown) {
+ return value === undefined ? null : value;
+}
+
+const CANVAS_OPERATION_FIELDS = ["title", "projectId", "chatSessions", "activeChatId", "starterMode", "appearance", "backgroundMode", "showImageInfo", "previsScenes", "timeline"] as const;
+
+export function buildCanvasOperations(base: CanvasProject | undefined, next: CanvasProject): CanvasOperation[] {
+ const operations: CanvasOperation[] = [];
+ if (!base) {
+ operations.push({
+ type: "create_canvas",
+ id: next.id,
+ patch: {
+ title: next.title,
+ projectId: next.projectId,
+ chatSessions: next.chatSessions,
+ activeChatId: next.activeChatId,
+ starterMode: next.starterMode,
+ appearance: next.appearance,
+ backgroundMode: next.backgroundMode,
+ showImageInfo: next.showImageInfo,
+ previsScenes: next.previsScenes,
+ timeline: next.timeline,
+ },
+ });
+ }
+ const baseNodes = new Map((base?.nodes || []).map((node) => [node.id, node]));
+ const nextNodes = new Map(next.nodes.map((node) => [node.id, node]));
+ for (const node of next.nodes) {
+ const previous = baseNodes.get(node.id);
+ if (!previous) {
+ operations.push({ type: "create_node", node });
+ continue;
+ }
+ const diff = diffCanvasObject(previous as unknown as Record, node as unknown as Record);
+ if (Object.keys(diff.patch).length) operations.push({ type: "patch_node", id: node.id, patch: diff.patch, before: diff.previous });
+ }
+ for (const node of base?.nodes || []) {
+ if (!nextNodes.has(node.id)) operations.push({ type: "remove_node", id: node.id, expected: node });
+ }
+ const baseConnections = new Map((base?.connections || []).map((connection) => [connection.id, connection]));
+ const nextConnections = new Map(next.connections.map((connection) => [connection.id, connection]));
+ for (const connection of next.connections) {
+ const previous = baseConnections.get(connection.id);
+ if (previous && !canvasOperationValueEqual(previous, connection)) {
+ operations.push({ type: "update_connection", id: connection.id, connection, expected: previous });
+ } else if (!previous) {
+ operations.push({ type: "create_connection", connection });
+ }
+ }
+ for (const connection of base?.connections || []) {
+ if (!nextConnections.has(connection.id)) operations.push({ type: "remove_connection", id: connection.id, expected: connection });
+ }
+ // 新画布的字段已由 create_canvas.patch 写入;再追加 set_field(beforeValue=null)
+ // 会在服务端看到刚创建的字段而判定冲突,所以只有已有基线时才生成字段操作。
+ if (!base) return operations;
+ for (const field of CANVAS_OPERATION_FIELDS) {
+ // 服务端用 "" 表示未关联项目,本地可能是 undefined;两者语义相同,不能生成写操作。
+ if (field === "projectId" && !base.projectId && !next.projectId) continue;
+ if (!canvasOperationValueEqual(base[field], next[field])) operations.push({ type: "set_field", field, value: canvasOperationValue(next[field]), beforeValue: canvasOperationValue(base[field]) });
+ }
+ return operations;
+}
+
+async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[] } = {}) {
const uploaded = new Map();
do {
syncQueued = false;
@@ -808,36 +970,28 @@ async function drainRemoteUserDataChanges(options: { projectId?: string | readon
} while (syncQueued);
}
-async function saveRemoteUserDataBatch(uploaded: Map, options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
+async function saveRemoteUserDataBatch(uploaded: Map, options: { projectId?: string | readonly string[] } = {}) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
const projectsBeforeRepair = useCanvasStore.getState().projects;
- const selectedProjectIds = options.projectId === undefined
- ? undefined
- : new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId);
- const syncProjectIds = selectedProjectIds === undefined
- ? undefined
- : new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id)));
+ const selectedProjectIds = options.projectId === undefined ? undefined : new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId);
+ const syncProjectIds = selectedProjectIds === undefined ? undefined : new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id)));
const changedProjectIds = new Set(
projectsBeforeRepair
- .filter((project) => (syncProjectIds === undefined || syncProjectIds.has(project.id)) && !sameCanvasContent(acknowledgedProjects.get(project.id), project))
+ .filter((project) => !remoteDeletedProjects.has(project.id) && (syncProjectIds === undefined || syncProjectIds.has(project.id)) && (pendingCanvasMutations.has(project.id) || !sameCanvasContent(acknowledgedProjects.get(project.id), project)))
.map((project) => project.id),
);
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => {
- const dirty = !sameCanvasContent(acknowledgedProjects.get(project.id), project);
- const conflicted = useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict";
+ const dirty = !remoteDeletedProjects.has(project.id) && (pendingCanvasMutations.has(project.id) || !sameCanvasContent(acknowledgedProjects.get(project.id), project));
const selected = selectedProjectIds === undefined || selectedProjectIds.has(project.id);
- return selected && dirty && (options.force || !conflicted);
+ return selected && dirty;
});
- const scopedAssetIds = options.force && syncProjectIds
- ? new Set(collectCanvasMediaAssetIds(currentProjects.filter((project) => syncProjectIds.has(project.id))))
- : undefined;
const dirtyAssets = currentAssets.filter((asset) => {
const dirty = !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset);
- return dirty && (scopedAssetIds === undefined || scopedAssetIds.has(asset.id));
+ return dirty;
});
if (!dirtyProjects.length && !dirtyAssets.length) return;
@@ -880,11 +1034,12 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
const url = resourceFileUrl(resourceId);
const displayUrl = result.kind === "image" ? result.data.dataUrl : result.data.url;
const coverUrl = result.coverUrl === displayUrl ? (result.kind === "image" ? url : "") : result.coverUrl;
- result = result.kind === "image"
- ? { ...result, coverUrl, data: { ...result.data, dataUrl: url } }
- : result.kind === "video"
- ? { ...result, coverUrl, data: { ...result.data, url } }
- : { ...result, coverUrl, data: { ...result.data, url } };
+ result =
+ result.kind === "image"
+ ? { ...result, coverUrl, data: { ...result.data, dataUrl: url } }
+ : result.kind === "video"
+ ? { ...result, coverUrl, data: { ...result.data, url } }
+ : { ...result, coverUrl, data: { ...result.data, url } };
}
}
return result;
@@ -925,7 +1080,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
merged = { ...merged, updatedAt: source.updatedAt };
mergedAssets.set(source.id, merged);
}
- useAssetStore.setState((state) => ({ assets: state.assets.map((item) => item.id === source.id ? merged : item) }));
+ useAssetStore.setState((state) => ({ assets: state.assets.map((item) => (item.id === source.id ? merged : item)) }));
}
}
@@ -934,11 +1089,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
const baseline = acknowledgedAssets.get(source.id);
if (!baseline || verifiedAssets.has(source.id)) continue;
const { asset } = await getRemoteAsset(source.id);
- if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) {
- if (!options.force) throw assetRemoteVersionConflict();
- // 强制覆盖是用户显式指令:采纳远端版本为新基线后继续用本地内容覆盖。
- acknowledgedAssets.set(source.id, asset);
- }
+ if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) throw assetRemoteVersionConflict();
verifiedAssets.add(source.id);
}
}
@@ -965,7 +1116,6 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
completed: 0,
phase: total > 0 ? "uploading" : "saving",
errorKind: undefined,
- draftError: undefined,
pendingChanges: 1,
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
});
@@ -975,129 +1125,83 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
}
};
try {
- let candidate = source;
- let saved = false;
- let lastError: unknown;
- for (let attempt = 0; attempt < 3 && !saved; attempt += 1) {
- if (!Number.isSafeInteger(candidate.revision) || candidate.revision! < 0) {
- throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
+ const current = useCanvasStore.getState().openProject(source.id);
+ if (!current) throw new Error("画布已关闭,停止提交操作");
+ const base = acknowledgedProjects.get(source.id);
+ const prepared = await ensureRemoteResourceReferences(current, uploaded, onMediaUploaded);
+ const pendingJournal = pendingCanvasMutations.get(source.id);
+ let preparedForMutation = prepared;
+ let savedRevision: number;
+ if (pendingJournal) {
+ preparedForMutation = pendingJournal.prepared;
+ const repairedRequest = repairPendingCanvasMutationRequest(pendingJournal.request, pendingJournal.baseline, pendingJournal.prepared);
+ if (repairedRequest !== pendingJournal.request) {
+ pendingJournal.request = repairedRequest;
+ await persistPendingCanvasMutationJournal();
}
- const hash = await canvasContentHash(candidate);
- const remotePayload = await ensureRemoteResourceReferences(candidate, uploaded, onMediaUploaded);
- if (total > 0) {
- useSyncProgressStore.getState().setProjectProgress(source.id, {
- phase: attempt > 0 ? "reconciling" : "saving",
- message: attempt > 0 ? "正在自动合并云端与本地修改" : "正在保存画布结构",
- });
+ useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "saving", message: "正在重放未确认的画布操作" });
+ // 旧队列可能只记录了 undefined 与缺省值之间的无效变化,没有需要发送的修改。
+ if (pendingJournal.request.operations.length) {
+ const response = await applyRemoteCanvasOperations(source.id, pendingJournal.request);
+ savedRevision = Number(response.project.revision);
+ } else {
+ savedRevision = Number(current.revision);
}
- try {
- const { project: savedProject } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload), { repairMissingResources: options.repairMissingResources === true });
- if (!Number.isSafeInteger(savedProject.revision) || savedProject.revision !== candidate.revision! + 1) {
- throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
- }
- const current = useCanvasStore.getState().openProject(source.id);
- if (current && current.revision !== candidate.revision) {
- throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
- }
- const acknowledged = { ...candidate, revision: savedProject.revision, remoteContentHash: hash };
- acknowledgedProjects.set(source.id, acknowledged);
+ pendingCanvasMutations.delete(source.id);
+ await persistPendingCanvasMutationJournal();
+ } else {
+ const operations = buildCanvasOperations(base, prepared);
+ if (!operations.length) {
+ acknowledgedProjects.set(source.id, { ...prepared, revision: current.revision, remoteContentHash: await canvasContentHash(prepared) });
verifiedProjects.add(source.id);
- if (current) {
- useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === current ? { ...project, revision: savedProject.revision, remoteContentHash: hash } : project)) }));
- }
- await flushCanvasStorePersistence();
- const pending = !sameCanvasContent(candidate, useCanvasStore.getState().openProject(source.id) || undefined);
- useSyncProgressStore.getState().setProjectProgress(source.id, {
- phase: pending ? "pending" : "done",
- cloudRevision: savedProject.revision,
- pendingChanges: pending ? 1 : 0,
- errorKind: undefined,
- draftError: undefined,
- message: pending ? "有新修改等待保存" : "已保存到云端",
- });
- if (pending) syncQueued = true;
- saved = true;
continue;
- } catch (error) {
- lastError = error;
- const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
- if (!conflict || attempt >= 2) throw error;
- try {
- const { project: remote } = await getRemoteCanvasProject(source.id);
- const current = useCanvasStore.getState().openProject(source.id);
- if (!current || current !== useCanvasStore.getState().openProject(source.id)) throw error;
- if (sameCanvasContent(current, remote)) {
- const remoteHash = await canvasContentHash(remote);
- const reconciled = { ...remote, viewport: current.viewport, remoteContentHash: remoteHash };
- acknowledgedProjects.set(source.id, reconciled);
- verifiedProjects.add(source.id);
- useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) }));
- await flushCanvasStorePersistence();
- useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", cloudRevision: reconciled.revision, pendingChanges: 0, errorKind: undefined, draftError: undefined, message: "云端内容一致,已自动校准版本" });
- saved = true;
- continue;
- }
- if (options.force) {
- const remoteHash = await canvasContentHash(remote);
- const forced = { ...current, revision: remote.revision, remoteContentHash: remoteHash };
- acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash });
- verifiedProjects.add(source.id);
- useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? forced : project)) }));
- await flushCanvasStorePersistence();
- candidate = forced;
- useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "正在用本地版本覆盖云端" });
- continue;
- }
- const baseline = acknowledgedProjects.get(source.id);
- if (!baseline) throw error;
- let rebased: CanvasProject;
- try {
- rebased = mergeAgentCanvasEditor(baseline, remote, current.nodes, current.connections, current);
- } catch (mergeError) {
- throw new ApiError(mergeError instanceof Error ? mergeError.message : "本地与云端存在同一字段修改", { status: 409 });
- }
- const remoteHash = await canvasContentHash(remote);
- if (useCanvasStore.getState().openProject(source.id) !== current) {
- throw new ApiError("画布在自动合并期间发生新修改,请重试", { status: 409 });
- }
- candidate = { ...rebased, viewport: current.viewport, remoteContentHash: remoteHash };
- acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash });
- verifiedProjects.add(source.id);
- useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? candidate : project)) }));
- await flushCanvasStorePersistence();
- useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "检测到云端更新,正在自动合并本地修改" });
- } catch (rebaseError) {
- lastError = rebaseError;
- throw rebaseError;
- }
}
+ const request: CanvasOperationsRequest = {
+ mutationId: newCanvasMutationId(),
+ ...(base ? { baseRevision: base.revision } : {}),
+ operations,
+ };
+ pendingCanvasMutations.set(source.id, { projectId: source.id, request, baseline: base, prepared, createdAt: new Date().toISOString() });
+ await persistPendingCanvasMutationJournal();
+ useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "saving", message: base ? "正在提交画布操作" : "正在创建画布" });
+ const response = await applyRemoteCanvasOperations(source.id, request);
+ savedRevision = Number(response.project.revision);
+ pendingCanvasMutations.delete(source.id);
+ await persistPendingCanvasMutationJournal();
}
- if (!saved && lastError) throw lastError;
- } catch (error) {
- const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
- const resourcesMissing = error instanceof ApiError && error.reason === "canvas_history_resources_missing";
- const errorKind = conflict ? "conflict" : resourcesMissing ? "resources" : error instanceof ApiError && error.retryable ? "network" : "local";
+ if (!Number.isSafeInteger(savedRevision)) throw new Error("服务端未返回有效画布版本");
+ const remote = await restoreRemoteTextNodeContents((await getRemoteCanvasProject(source.id)).project);
+ const latest = useCanvasStore.getState().openProject(source.id);
+ if (!latest) throw new Error("画布已关闭,停止提交操作");
+ const remoteHash = await canvasContentHash(remote);
+ const acknowledged = { ...remote, viewport: latest.viewport || remote.viewport, remoteContentHash: remoteHash };
+ acknowledgedProjects.set(source.id, acknowledged);
+ verifiedProjects.add(source.id);
+ if (sameCanvasContent(latest, preparedForMutation)) {
+ useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === latest ? acknowledged : project)) }));
+ } else {
+ useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project === latest ? { ...project, revision: remote.revision, remoteContentHash: remoteHash } : project)) }));
+ }
+ await flushCanvasStorePersistence();
+ const currentAfterSave = useCanvasStore.getState().openProject(source.id);
+ const pending = Boolean(currentAfterSave && !sameCanvasContent(acknowledged, currentAfterSave));
useSyncProgressStore.getState().setProjectProgress(source.id, {
- phase: conflict ? "conflict" : "error",
- errorKind,
- draftError: undefined,
- pendingChanges: 1,
- message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试",
+ phase: pending ? "pending" : "done",
+ cloudRevision: Number(remote.revision),
+ pendingChanges: pending ? 1 : 0,
+ errorKind: undefined,
+ message: pending ? "有新修改等待保存" : "已保存到云端",
+ });
+ if (pending) syncQueued = true;
+ } catch (error) {
+ const resourcesMissing = error instanceof ApiError && error.reason === "canvas_history_resources_missing";
+ const errorKind = resourcesMissing ? "resources" : error instanceof ApiError && error.retryable ? "network" : "local";
+ useSyncProgressStore.getState().setProjectProgress(source.id, {
+ phase: "error",
+ errorKind,
+ pendingChanges: 1,
+ message: error instanceof Error ? error.message : "云端同步失败,等待自动恢复",
});
- if (conflict) {
- try {
- const current = useCanvasStore.getState().openProject(source.id) || source;
- const draftCount = await preserveCanvasSyncDraft(current);
- useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount, draftError: undefined });
- } catch (draftError) {
- useSyncProgressStore.getState().setProjectProgress(source.id, {
- draftError: draftError instanceof Error ? draftError.message : "本地草稿保存失败",
- errorKind: "draft",
- message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变",
- });
- errors.push(draftError);
- }
- }
errors.push(error);
}
}
@@ -1106,7 +1210,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
}
function assetRemoteVersionConflict() {
- return new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库");
+ return new Error("素材远端基线已变化,未确认修改会在下一轮同步中重新校验");
}
function requireRemoteUserDataBaseline() {
@@ -1116,42 +1220,3 @@ function requireRemoteUserDataBaseline() {
function sameEntitySnapshot(acknowledged: T | undefined, current: T) {
return acknowledged !== undefined && (acknowledged === current || JSON.stringify(acknowledged) === JSON.stringify(current));
}
-
-function sanitizeCanvasProjectForRemoteSync(project: T): T {
- if (!project || typeof project !== "object") return project;
- const { viewport: _viewport, ...clone } = project as Record;
- if (Array.isArray(clone.chatSessions)) {
- clone.chatSessions = clone.chatSessions.map((session) => {
- if (!session || typeof session !== "object") return session;
- const s = { ...(session as Record) };
- if (Array.isArray(s.messages)) {
- s.messages = s.messages.map((message) => {
- if (!message || typeof message !== "object" || !message.detail) return message;
- const m = { ...(message as Record) };
- if (m.detail && typeof m.detail === "object") {
- const d = { ...(m.detail as Record) };
- if (Array.isArray(d.results)) {
- d.results = d.results.map((r) => {
- if (!r || typeof r !== "object") return r;
- const res = { ...(r as Record) };
- if (res.result && typeof res.result === "object") {
- const inner = { ...(res.result as Record) };
- if (inner.data && typeof inner.data === "object") {
- const { snapshot: _s, before: _b, after: _a, ...restData } = inner.data as Record;
- inner.data = restData;
- }
- res.result = inner;
- }
- return res;
- });
- }
- m.detail = d;
- }
- return m;
- });
- }
- return s;
- });
- }
- return clone as T;
-}
diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts
index 563b01d20..e4d873ed4 100644
--- a/web/src/stores/canvas/use-canvas-store.ts
+++ b/web/src/stores/canvas/use-canvas-store.ts
@@ -151,20 +151,19 @@ function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal:
}
async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType["conflicts"]) {
- if (!conflicts.length) return;
+ if (!conflicts.length || getActiveUserScope() !== scope) return;
const ids = new Set(conflicts.map((conflict) => conflict.projectId || conflict.id));
- const { preserveCanvasSyncDraftLocked } = await import("@/services/canvas-sync-drafts");
for (const project of projects.filter((item) => ids.has(item.id))) {
- const draftCount = await preserveCanvasSyncDraftLocked(project, scope);
- if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(project.id, {
- phase: "conflict", errorKind: "conflict", draftCount,
- message: "其他标签页也修改了此画布,本地版本已保留为草稿",
+ useSyncProgressStore.getState().setProjectProgress(project.id, {
+ phase: "pending",
+ pendingChanges: 1,
+ errorKind: "conflict",
+ message: "其他标签页也修改了此画布,未确认操作会按字段重放",
});
}
}
-
function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial) {
const progress = useSyncProgressStore.getState();
for (const project of projects) {
@@ -462,6 +461,11 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys];
else delete metadata.generationEffectKeys;
nodes.push({ ...rolledBack, metadata });
+ } else {
+ // There is no durable ancestor for a locally-created result.
+ // Preserve it in the recovery snapshot; the effect key makes a
+ // later replay idempotent and the user must not lose the node.
+ nodes.push(node);
}
continue;
}
@@ -506,11 +510,14 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
chatSessions.push(nextSession);
}
if (durableProject && hasUnconfirmedGeneration) {
+ const localConnectionIds = new Set(project.connections.map((connection) => connection.id));
return {
...project,
nodes,
- // Connection/active-chat records carry no effect stamp provenance, so keep them fail-closed while any generation entity is unconfirmed.
- connections: durableProject.connections,
+ // Connection/active-chat records carry no effect stamp provenance. Keep
+ // local connections and append durable-only ones instead of replacing
+ // the whole list and dropping edits made during generation.
+ connections: [...project.connections, ...durableProject.connections.filter((connection) => !localConnectionIds.has(connection.id))],
chatSessions,
activeChatId: durableProject.activeChatId,
};
diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts
index 61126e84a..ba8a63b14 100644
--- a/web/src/stores/use-asset-store.ts
+++ b/web/src/stores/use-asset-store.ts
@@ -13,7 +13,6 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm
import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage";
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository";
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
-import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
export type { AssetCategory } from "@/lib/asset-category";
@@ -443,7 +442,6 @@ export const useAssetStore = create()(
await new Promise((resolve, reject) => {
window.setTimeout(() =>
withGenerationArtifactCommitLock(scope, async () => {
- const syncDrafts = await readAllCanvasSyncDrafts(scope);
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
await commitPendingCanvasStorePersistenceLocked(scope);
@@ -453,7 +451,7 @@ export const useAssetStore = create()(
await withGenerationAssetStorageLock(scope, async () => {
await commitPendingAssetStorePersistenceLocked(scope);
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
- const references = { projects: canvasProjects, assets: durableAssets, syncDrafts };
+ const references = { projects: canvasProjects, assets: durableAssets };
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
await cleanupUnusedImages(
diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts
index f974d4d93..3835f7821 100644
--- a/web/src/stores/use-config-store.ts
+++ b/web/src/stores/use-config-store.ts
@@ -362,7 +362,7 @@ export const useConfigStore = create()(
createModelChannel({
...channel,
id: channel.id || `system-${index + 1}`,
- name: channel.name || `系统渠道 ${index + 1}`,
+ name: "系统模型",
scope: "system",
apiKey: channel.apiKey || "system",
}),
diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts
index efe4007ea..1f15fa520 100644
--- a/web/src/stores/use-sync-progress-store.ts
+++ b/web/src/stores/use-sync-progress-store.ts
@@ -6,11 +6,9 @@ export type SyncProjectProgress = {
projectId: string;
total: number;
completed: number;
- phase: "local" | "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict";
+ phase: "local" | "pending" | "uploading" | "saving" | "done" | "error";
message?: string;
- draftCount?: number;
- draftError?: string;
- errorKind?: "local" | "network" | "conflict" | "draft" | "resources";
+ errorKind?: "local" | "network" | "conflict" | "resources";
localPhase?: LocalCanvasPersistencePhase;
localSavedAt?: string;
localError?: string;
@@ -66,7 +64,7 @@ export const useSyncProgressStore = create((set, get) => ({
clearAll: () => set({ syncingProjects: {} }),
isAnySyncing: () => {
const list = Object.values(get().syncingProjects);
- return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error" || Boolean(item.draftError));
+ return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error");
},
}));
diff --git a/web/src/styles/auth-input-fix.css b/web/src/styles/auth-input-fix.css
new file mode 100644
index 000000000..f63380c74
--- /dev/null
+++ b/web/src/styles/auth-input-fix.css
@@ -0,0 +1,11 @@
+/* 必须留在 utilities 层内:原聚焦阴影同样位于该层且使用 !important,
+ 层外样式无法覆盖层内的 important 声明。 */
+@layer utilities {
+ .auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus,
+ .auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus-visible {
+ border-color: transparent !important;
+ outline: none !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ }
+}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index b5a783a22..289debede 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -15522,11 +15522,10 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
min-height: 38px;
flex: 1 1 auto;
align-items: center;
- justify-content: space-between;
+ justify-content: center;
gap: 6px;
border-radius: 10px;
padding: 0 7px;
- text-align: left;
transition: background-color 160ms cubic-bezier(.16, 1, .3, 1);
}
@@ -16193,12 +16192,13 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
border-radius: 12px;
color: #222;
text-align: left;
- transition: background-color .18s ease, transform .18s ease;
+ transition: transform .18s ease;
}
.app-user-workspace .app-workspace-sidebar-profile:hover,
.app-user-workspace .app-workspace-sidebar-profile:focus-visible {
- background: rgba(72, 72, 73, .055);
+ background: var(--user-surface-hover);
+ color: var(--user-ink);
}
.app-user-workspace .app-workspace-sidebar-profile.is-collapsed {
@@ -16301,6 +16301,52 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
height: 16px;
}
+.app-user-workspace .app-workspace-sidebar-collapse-toggle {
+ display: grid;
+ width: 40px;
+ height: 40px;
+ flex: 0 0 40px;
+ place-items: center;
+ border-radius: 10px;
+ color: var(--user-ink-muted);
+}
+
+.app-user-workspace .app-workspace-sidebar-collapse-toggle:hover,
+.app-user-workspace .app-workspace-sidebar-collapse-toggle:focus-visible {
+ background: var(--user-surface-hover);
+ color: var(--user-ink);
+}
+
+.app-user-workspace .app-workspace-sidebar-collapse-toggle svg {
+ width: 18px;
+ height: 18px;
+ transition: transform var(--user-motion, .18s) ease;
+}
+
+.app-user-workspace .app-workspace-sidebar-collapse-toggle:hover svg {
+ transform: translateY(-1px);
+}
+
+.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-collapse-toggle {
+ width: 48px;
+ height: 48px;
+ flex: 0 0 48px;
+}
+
+.dark .app-user-workspace .app-workspace-sidebar-collapse-toggle {
+ color: rgba(255, 255, 255, 0.65) !important;
+}
+
+.dark .app-user-workspace .app-workspace-sidebar-collapse-toggle:hover,
+.dark .app-user-workspace .app-workspace-sidebar-collapse-toggle:focus-visible {
+ background: rgba(255, 255, 255, 0.08) !important;
+ color: #ffffff !important;
+}
+
+.dark .app-user-workspace .app-workspace-sidebar-collapse-toggle svg {
+ color: currentColor !important;
+}
+
.app-user-workspace .app-workspace-sidebar-profile-popover {
width: 230px;
}
diff --git a/web/src/styles/workspace-product.css b/web/src/styles/workspace-product.css
index 483e13a72..8ae7ae652 100644
--- a/web/src/styles/workspace-product.css
+++ b/web/src/styles/workspace-product.css
@@ -551,9 +551,10 @@
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-account { width: 100%; align-items: center; }
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-checkin { width: 48px; }
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-storage { width: 40px; }
-.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-profile-row { width: 100%; justify-content: center; }
-.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-profile { width: 48px; min-width: 48px; flex: 0 0 48px; margin-inline: auto; }
+.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-profile-row { width: 100%; justify-content: center; gap: 4px; }
+.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-profile { width: 48px; min-width: 48px; flex: 0 0 48px; }
.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-notification-spacer { display: none; }
+.app-user-workspace .app-workspace-sidebar-nav.is-collapsed .app-workspace-sidebar-collapse-toggle.is-collapsed { margin-top: 4px; }
.app-user-workspace .app-workspace-nav-link {
position: relative;
@@ -713,6 +714,13 @@
.app-product-workspace button:has(> svg):not(:has(> span)):focus-visible,
.app-product-overlays button:has(> svg):not(:has(> span)):focus-visible { outline: 2px solid var(--product-focus-ring); outline-offset: 2px; }
+/* 侧栏自绘图标按钮(收起/展开切换等)不套用浮层 accent 光晕,悬浮表现与导航菜单保持一致。 */
+.app-user-workspace .app-workspace-sidebar button:has(> svg):not(:has(> span)):not(:disabled):hover {
+ color: var(--user-ink) !important;
+ background: var(--user-surface-hover) !important;
+ box-shadow: none !important;
+}
+
/* 列表进入节奏:卡片和图片错开出现,慢网络时仍有内容层次。 */
.app-product-workspace :is(.product-collection-card, .library-card, .task-grid-card, .task-record-row, .skill-library-card, .plugin-card) { animation: product-card-enter var(--product-enter) var(--product-ease) both; }
.app-product-workspace :is(.product-collection-card, .library-card, .task-grid-card, .task-record-row, .skill-library-card, .plugin-card):nth-child(2) { animation-delay: 45ms; }
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index cc09e9d7f..018a554c9 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -50,7 +50,6 @@ export function isBuiltinCanvasNodeType(type: CanvasNodeTypeId): type is CanvasN
}
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
-export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance";
export type CanvasWorkspaceMode = "simple" | "professional";
export type CanvasToolMode = "move" | "box-select";
export type CanvasFolderStyle = "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact";
diff --git a/web/test/agent-canvas-patch.test.ts b/web/test/agent-canvas-patch.test.ts
index e2fea2e7c..196c1bf51 100644
--- a/web/test/agent-canvas-patch.test.ts
+++ b/web/test/agent-canvas-patch.test.ts
@@ -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.
diff --git a/web/test/agent-canvas-refresh.test.ts b/web/test/agent-canvas-refresh.test.ts
deleted file mode 100644
index 0ea15e55c..000000000
--- a/web/test/agent-canvas-refresh.test.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
-import localforage from "localforage";
-import { canvasContentHash } from "@/lib/canvas/canvas-content";
-import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
-import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
-import { http } from "@/services/api/request";
-import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh, saveRemoteUserDataNow } from "@/services/user-data-sync";
-import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
-import { CanvasNodeType } from "@/types/canvas";
-
-const initial: CanvasProject = { id: "agent-test", revision: 1, title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } };
-const remote: CanvasProject = { ...initial, revision: 2, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] };
-let unsubscribe = () => {};
-let restore = () => {};
-const originalGet = localforage.getItem;
-const originalSet = localforage.setItem;
-const originalWindow = globalThis.window;
-const indexed = new Map();
-beforeEach(() => {
- resetRemoteUserDataSync();
- Object.defineProperty(globalThis, "window", {
- configurable: true,
- value: {
- setTimeout: () => 1,
- clearTimeout: () => {},
- localStorage: { getItem: () => "agent-user", setItem: () => {} },
- },
- });
- indexed.clear();
- localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
- localforage.setItem = (async (key: string, value: unknown) => {
- indexed.set(key, value);
- return value;
- }) as typeof localforage.setItem;
-});
-afterEach(async () => {
- unsubscribe();
- restore();
- resetRemoteUserDataSync();
- await flushCanvasStorePersistence();
- localforage.getItem = originalGet;
- localforage.setItem = originalSet;
- if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
- else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
-});
-
-async function setup(project: CanvasProject = remote) {
- initial.remoteContentHash = await canvasContentHash(initial);
- useCanvasStore.setState({ projects: [initial] });
- await initializeRemoteUserDataSession("agent-user");
- const mock = spyOn(http, "get").mockResolvedValue({ project });
- restore = () => mock.mockRestore();
- return mock;
-}
-
-test("successful Agent refresh projects nodes into the open editor and store", async () => {
- await setup();
- let editor = initial;
- unsubscribe = subscribeAgentCanvasRefresh((project, previous) => {
- expect(previous).toEqual(initial);
- editor = project;
- });
- await refreshCanvasAfterAgent(initial.id);
- expect(editor.nodes).toEqual(remote.nodes);
- expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
-});
-
-test("unsaved editor conflicts preserve store and acknowledged baseline", async () => {
- await setup();
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- throw new Error("本地编辑冲突");
- });
- await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地编辑冲突");
- expect(useCanvasStore.getState().projects[0]).toEqual(initial);
- unsubscribe();
- await refreshCanvasAfterAgent(initial.id);
- expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
-});
-
-test("dirty persisted edits are rebased when Agent changes different fields", async () => {
- await setup();
- useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
- let notified = false;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- notified = true;
- });
- await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
- expect(notified).toBe(true);
- expect(useCanvasStore.getState().projects[0]).toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
- expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
-});
-
-test("dirty persisted edits still conflict when Agent changes the same field", async () => {
- const mock = await setup({ ...remote, title: "云端修改" });
- useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
- mock.mockResolvedValue({ project: { ...remote, title: "云端修改" } });
- await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow();
- expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
- expect(useCanvasStore.getState().projects[0].title).toBe("本地修改");
-});
-
-test("automatic focus and panning do not block Agent updates or reset the local viewport", async () => {
- await setup();
- const viewport = { x: -800, y: -400, k: 0.7 };
- useCanvasStore.setState({ projects: [{ ...initial, viewport, updatedAt: "2026-09-13" }] });
- let editor = initial;
- unsubscribe = subscribeAgentCanvasRefresh((project) => {
- editor = project;
- });
- await refreshCanvasAfterAgent(initial.id);
- expect(editor.nodes).toEqual(remote.nodes);
- expect(editor.viewport).toEqual(viewport);
- expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
-});
-
-test("refresh-only Previs mutations do not conflict with an editor-only viewport change", async () => {
- const scene = {
- id: "scene-1",
- version: 1 as const,
- title: "镜头",
- background: "#111111",
- environmentIntensity: 0.7,
- environment: { mode: "color" as const },
- gridVisible: true,
- objects: [],
- cameras: [],
- lights: [],
- shots: [],
- activeShotId: "shot-1",
- createdAt: "2026-09-13T00:00:00Z",
- updatedAt: "2026-09-13T00:00:00Z",
- };
- const local = { ...initial, previsScenes: [scene] };
- local.remoteContentHash = await canvasContentHash(local);
- const remoteWithPrevis = {
- ...local,
- revision: 2,
- previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }],
- };
- useCanvasStore.setState({ projects: [local] });
- await initializeRemoteUserDataSession("agent-user");
- const mock = spyOn(http, "get").mockResolvedValue({ project: remoteWithPrevis });
- restore = () => mock.mockRestore();
- const viewport = { x: -640, y: -280, k: 0.68 };
- useCanvasStore.setState({ projects: [{ ...local, viewport, updatedAt: "2026-09-13T12:01:00Z" }] });
- await refreshCanvasAfterAgent(local.id);
- expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
- expect(useCanvasStore.getState().projects[0].previsScenes).toEqual(remoteWithPrevis.previsScenes);
- expect(useSyncProgressStore.getState().syncingProjects[local.id].phase).toBe("done");
-});
-
-test("replayed completion events do not reapply unchanged canvas nodes", async () => {
- await setup();
- let deliveries = 0;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- deliveries += 1;
- });
- await refreshCanvasAfterAgent(initial.id);
- await refreshCanvasAfterAgent(initial.id);
- expect(deliveries).toBe(1);
-});
-
-test("media admission and completion refresh the editor with references and result", async () => {
- const video = { id: "video-1", type: CanvasNodeType.Video, title: "镜头1", position: { x: 500, y: 20 }, width: 360, height: 640, metadata: { taskId: "task-1", status: "loading" as const, referenceNodeIds: ["image-1"] } };
- const project: CanvasProject = { ...remote, nodes: [...remote.nodes, { ...video, id: "image-1", type: CanvasNodeType.Image }, video], connections: [{ id: "edge-1", fromNodeId: "image-1", toNodeId: "video-1" }] };
- const mock = await setup(project);
- let editor = initial;
- unsubscribe = subscribeAgentCanvasRefresh((updated) => {
- editor = updated;
- });
- await refreshCanvasAfterAgent(initial.id);
- expect(editor.connections).toEqual(project.connections);
- expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]);
- const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => (node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node)) };
- mock.mockResolvedValue({ project: complete });
- await refreshCanvasAfterAgent(initial.id);
- expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result");
- expect(useCanvasStore.getState().projects[0].nodes).toEqual(complete.nodes);
-});
-
-test("incremental batches update the editor once without full canvas requests", async () => {
- const get = await setup();
- let deliveries = 0;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- deliveries++;
- });
- const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
- await applyAgentCanvasPatches(initial.id, [patch, patch]);
- expect(get).not.toHaveBeenCalled();
- expect(deliveries).toBe(1);
- expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
-});
-
-test("incremental batches sort and deduplicate revisions before applying", async () => {
- await setup();
- const first = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-1" } }], connections: [] };
- const second = { canvasId: initial.id, baseRevision: 2, revision: 3, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-2" } }], connections: [] };
- await applyAgentCanvasPatches(initial.id, [second, first, first]);
- expect(useCanvasStore.getState().projects[0].revision).toBe(3);
- expect(useCanvasStore.getState().projects[0].nodes.map((node) => node.id)).toEqual(["agent-1", "agent-2"]);
-});
-test("already projected deltas still advance the remote baseline", async () => {
- await setup();
- useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] });
- let deliveries = 0;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- deliveries++;
- });
- await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
- expect(deliveries).toBe(0);
- await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote);
- expect(deliveries).toBe(0);
-});
-
-test("editor rejection keeps delta projection atomic and retryable", async () => {
- await setup();
- const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- throw new Error("编辑冲突");
- });
- await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突");
- expect(useCanvasStore.getState().projects[0]).toEqual(initial);
- unsubscribe();
- await applyAgentCanvasPatches(initial.id, [patch]);
- expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
-});
-
-test("50 distinct media completions project once and keep the local viewport", async () => {
- const get = await setup();
- const nodes = Array.from({ length: 50 }, (_, index) => ({
- id: `video-${index}`,
- title: `动画${index}`,
- type: CanvasNodeType.Video,
- position: { x: index * 400, y: 100 },
- width: 320,
- height: 180,
- metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" },
- }));
- const pending = { ...initial, nodes };
- useCanvasStore.setState({ projects: [pending] });
- pending.remoteContentHash = await canvasContentHash(pending);
- await initializeRemoteUserDataSession("agent-user");
- let deliveries = 0;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- deliveries++;
- });
- await applyAgentCanvasPatches(
- initial.id,
- nodes.map((before, index) => ({
- canvasId: initial.id,
- baseRevision: index + 1,
- revision: index + 2,
- updatedAt: initial.updatedAt,
- nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? ("error" as const) : ("success" as const), taskStatus: index % 2 ? "failed" : "succeeded" } } }],
- connections: [],
- })),
- );
- const result = useCanvasStore.getState().projects[0];
- expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25);
- expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25);
- expect(result.viewport).toEqual(initial.viewport);
- expect(deliveries).toBe(1);
- expect(get).not.toHaveBeenCalled();
-});
-
-test("a delta gap reconciles against the cloud before declaring a conflict", async () => {
- await setup();
- await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
- expect(useCanvasStore.getState().projects[0].revision).toBe(1);
- expect(useSyncProgressStore.getState().syncingProjects[initial.id]?.phase).not.toBe("conflict");
- expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
- await refreshCanvasAfterAgent(initial.id);
- expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
- expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("done");
-});
-
-test("reconciliation after a delta gap still protects genuinely conflicting edits", async () => {
- await setup({ ...remote, title: "云端标题" });
- useCanvasStore.setState({ projects: [{ ...initial, title: "未保存标题" }] });
- await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
- await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("冲突");
- expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
- expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe("未保存标题");
- await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新");
-});
-
-test("an identical cloud snapshot clears a stale conflict without replaying editor nodes", async () => {
- await setup(initial);
- useSyncProgressStore.getState().setProjectProgress(initial.id, { phase: "conflict", draftCount: 9 });
- let deliveries = 0;
- unsubscribe = subscribeAgentCanvasRefresh(() => {
- deliveries++;
- });
- await refreshCanvasAfterAgent(initial.id);
- expect(deliveries).toBe(0);
- expect(useSyncProgressStore.getState().syncingProjects[initial.id]).toMatchObject({ phase: "done", draftCount: 9 });
- await expect(saveRemoteUserDataNow(initial.id)).resolves.toBeUndefined();
-});
-
-test("replayed events against an unchanged cloud ancestor preserve pending local edits", async () => {
- await setup(initial);
- await refreshCanvasAfterAgent(initial.id);
- const edited = { ...initial, title: "等待保存的标题" };
- useCanvasStore.setState({ projects: [edited] });
- await refreshCanvasAfterAgent(initial.id);
- expect(useCanvasStore.getState().projects[0]).toBe(edited);
- expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
- expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
-});
-
-test("an unverified dirty cache cannot become a save baseline merely because revisions match", async () => {
- await setup(initial);
- const edited = { ...initial, title: "离线标题" };
- useCanvasStore.setState({ projects: [edited] });
- await initializeRemoteUserDataSession("agent-user");
- await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("缺少画布合并基线");
- expect(useCanvasStore.getState().projects[0]).toBe(edited);
- expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe(edited.title);
-});
-
-test("Agent updates merge a local title edit and the next save uses the new revision", async () => {
- await setup();
- useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] });
- const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
- await applyAgentCanvasPatches(initial.id, [patch]);
- const merged = useCanvasStore.getState().projects[0];
- expect(merged.title).toBe("本地标题");
- expect(merged.revision).toBe(2);
- expect(merged.nodes).toEqual(remote.nodes);
- const put = spyOn(http, "put").mockImplementation(async (_url, body) => {
- const project = (body as { project: CanvasProject }).project;
- expect(project.revision).toBe(2);
- return { project: { ...project, revision: 3 } } as never;
- });
- try {
- await saveRemoteUserDataNow(initial.id);
- expect(put).toHaveBeenCalledTimes(1);
- await applyAgentCanvasPatches(initial.id, [patch]);
- expect(useCanvasStore.getState().projects[0].revision).toBe(3);
- expect(useCanvasStore.getState().projects[0].title).toBe("本地标题");
- } finally {
- put.mockRestore();
- }
-});
diff --git a/web/test/agent-media-approval.test.ts b/web/test/agent-media-approval.test.ts
index fa5f0eaba..9dc936a93 100644
--- a/web/test/agent-media-approval.test.ts
+++ b/web/test/agent-media-approval.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
-import { AGENT_APPROVAL_SUPERSEDED_BY_NODE, agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentApprovalTargetGenerating, agentImageApproval, agentMediaApprovalTargetNodeId } from "../src/lib/canvas/agent-media-approval";
+import { AGENT_APPROVAL_SUPERSEDED_BY_NODE, agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentApprovalTargetGenerating, agentImageApproval, agentMediaApprovalTargetNodeId, agentVideoApproval } from "../src/lib/canvas/agent-media-approval";
import type { CanvasNodeData } from "../src/types/canvas";
import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store";
@@ -40,6 +40,15 @@ describe("image generation approval settings", () => {
});
});
+describe("video generation approval settings", () => {
+ it("reads video duration and audio options", () => {
+ const approval = agentVideoApproval({ toolName: "generate_media", arguments: { mode: "video", prompt: "镜头运动", size: "9:16", quality: "720p", durationSeconds: 8, videoGenerateAudio: true, channelId: "platform", channelModelKey: "seedance-test", referenceNodeIds: ["image"] } });
+ expect(approval?.durationSeconds).toBe(8);
+ expect(approval?.videoGenerateAudio).toBe(true);
+ expect(approval?.referenceNodeIds).toEqual(["image"]);
+ });
+});
+
describe("approval target already generating on the canvas", () => {
const detail = { toolName: "generate_media", arguments: JSON.stringify({ mode: "image", prompt: "p", size: "1:1", nodeId: "draft-1" }) };
const node = (metadata: Record) => ({ id: "draft-1", type: "image", title: "开场分镜", position: { x: 0, y: 0 }, width: 200, height: 200, metadata }) as unknown as CanvasNodeData;
diff --git a/web/test/canvas-connection-tilt.test.ts b/web/test/canvas-connection-tilt.test.ts
index e6dc6a4a8..a635c367a 100644
--- a/web/test/canvas-connection-tilt.test.ts
+++ b/web/test/canvas-connection-tilt.test.ts
@@ -26,7 +26,7 @@ test("world-space approach is independent of viewport scaling", () => {
test("only connection targets receive approach data; memo tracks it and selection does not activate tilt", () => {
const source = readFileSync(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url), "utf8");
const layers = readFileSync(new URL("../src/pages/canvas/canvas-project-world-layers.tsx", import.meta.url), "utf8");
- expect(source).toContain("isConnectionTarget && !reduceMediaEffects && !dragOffset");
+ expect(source).toContain("isConnectionTarget && !disableMediaEffects && !dragOffset");
expect(source).toContain("previous.connectionApproach?.x === next.connectionApproach?.x");
expect(source).toContain('data-connection-tilt={connectionTilt ? "true" : undefined}');
expect(layers).toContain("props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined");
diff --git a/web/test/canvas-grid-viewport.test.tsx b/web/test/canvas-grid-viewport.test.tsx
index 1ba88868e..3297cb44d 100644
--- a/web/test/canvas-grid-viewport.test.tsx
+++ b/web/test/canvas-grid-viewport.test.tsx
@@ -70,8 +70,9 @@ describe("canvas screen-space background", () => {
for (const viewport of viewports) {
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewport, false);
expect(world.style.transform).toBe(`translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / 0.5})`);
- expect(properties.get("--canvas-live-scale")).toBe(String(viewport.k));
- expect(properties.get("--canvas-live-inverse-scale")).toBe(String(1 / viewport.k));
+ // 交互期不改继承变量,避免整棵节点子树在每一帧强制重算样式。
+ expect(properties.has("--canvas-live-scale")).toBe(false);
+ expect(properties.has("--canvas-live-inverse-scale")).toBe(false);
expect(gridWrites).toEqual([]);
}
expect(graphics).toEqual(viewports);
@@ -81,6 +82,8 @@ describe("canvas screen-space background", () => {
container.dataset.canvasViewportInteracting = "false";
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewports[0]);
+ expect(properties.get("--canvas-live-scale")).toBe("1");
+ expect(properties.get("--canvas-live-inverse-scale")).toBe("1");
expect(world.style.willChange).toBe("");
expect(previews).toEqual([viewports[0]]);
expect(scrolls).toBe(1);
diff --git a/web/test/canvas-media-performance.test.ts b/web/test/canvas-media-performance.test.ts
index 6e35562a6..681a45481 100644
--- a/web/test/canvas-media-performance.test.ts
+++ b/web/test/canvas-media-performance.test.ts
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
-import { canvasNodeRenderPadding, resolveActiveCanvasMediaNodeId } from "../src/lib/canvas/canvas-performance-mode";
+import { canvasNodeRenderPadding } from "../src/lib/canvas/canvas-render-budget";
import { videoMetadata } from "../src/lib/canvas/canvas-generation-task-sync";
import { canvasNodeVideoPreviewReference, canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "../src/lib/canvas/canvas-media-preview";
import { collectImageStorageKeys } from "../src/services/image-storage";
@@ -34,23 +34,6 @@ function node(id: string, type: CanvasNodeType): CanvasNodeData {
return { id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} };
}
-describe("canvas media activation", () => {
- const nodes = [node("video", CanvasNodeType.Video), node("audio", CanvasNodeType.Audio), node("image", CanvasNodeType.Image)];
- const nodeById = new Map(nodes.map((item) => [item.id, item]));
-
- test("activates exactly one selected video or audio node", () => {
- expect(resolveActiveCanvasMediaNodeId(new Set(["video"]), nodeById)).toBe("video");
- expect(resolveActiveCanvasMediaNodeId(new Set(["audio"]), nodeById)).toBe("audio");
- });
-
- test("keeps media inactive for empty, multi-selection, and non-media selection", () => {
- expect(resolveActiveCanvasMediaNodeId(new Set(), nodeById)).toBeNull();
- expect(resolveActiveCanvasMediaNodeId(new Set(["video", "audio"]), nodeById)).toBeNull();
- expect(resolveActiveCanvasMediaNodeId(new Set(["image"]), nodeById)).toBeNull();
- expect(resolveActiveCanvasMediaNodeId(new Set(["missing"]), nodeById)).toBeNull();
- });
-});
-
describe("canvas dimension header rendering", () => {
test("tracks live viewport scale without React width commits", () => {
expect(canvasNodeSource).toContain("calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))");
@@ -60,9 +43,8 @@ describe("canvas dimension header rendering", () => {
describe("large canvas media rendering", () => {
test("keeps rendered nodes mounted longer than newly entering nodes", () => {
- expect(canvasNodeRenderPadding(true, false)).toBe(128);
- expect(canvasNodeRenderPadding(true, true)).toBe(640);
- expect(canvasNodeRenderPadding(false, true)).toBeGreaterThan(canvasNodeRenderPadding(false, false));
+ expect(canvasNodeRenderPadding(false)).toBe(192);
+ expect(canvasNodeRenderPadding(true)).toBe(384);
});
test("does not eagerly load or resize LibTV thumbnails", () => {
@@ -76,6 +58,12 @@ describe("large canvas media rendering", () => {
expect(canvasProjectSource).toContain("");
});
+ test("decodes canvas images synchronously so zoom re-rasterization never paints blank frames", () => {
+ const retainedImageSource = canvasNodeContentSource.match(/export function RetainedCanvasImage[\s\S]*?\n}\n/)?.[0] || "";
+ expect(retainedImageSource).toContain('decoding="sync"');
+ expect(retainedImageSource).not.toContain('decoding="async"');
+ });
+
test("keeps node shells visible while panning and zooming", () => {
expect(globalStylesSource).not.toMatch(/\[data-canvas-viewport-interacting="true"\]\s+\.canvas-node-shell\s*\{[^}]*content-visibility:\s*hidden/);
});
diff --git a/web/test/canvas-missing-resources.test.ts b/web/test/canvas-missing-resources.test.ts
new file mode 100644
index 000000000..d3edd4b27
--- /dev/null
+++ b/web/test/canvas-missing-resources.test.ts
@@ -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" } }],
+ });
+ });
+});
diff --git a/web/test/canvas-operations-build.test.ts b/web/test/canvas-operations-build.test.ts
new file mode 100644
index 000000000..9bd18a8d3
--- /dev/null
+++ b/web/test/canvas-operations-build.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test";
+
+import { buildCanvasOperations, repairPendingCanvasMutationRequest } from "@/services/user-data-sync";
+import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
+
+function canvas(patch: Partial = {}): CanvasProject {
+ return {
+ id: "canvas",
+ title: "自由画布 1",
+ createdAt: "",
+ updatedAt: "",
+ nodes: [],
+ connections: [],
+ chatSessions: [],
+ activeChatId: null,
+ backgroundMode: "dots" as CanvasProject["backgroundMode"],
+ showImageInfo: false,
+ viewport: { x: 0, y: 0, k: 1 },
+ previsScenes: [],
+ ...patch,
+ };
+}
+
+describe("画布 operations 生成", () => {
+ test("节点可选字段的 undefined 在 JSON 请求中保留为 null 基线和删除值", () => {
+ const node = { id: "node", metadata: { title: undefined } } as unknown as CanvasProject["nodes"][number];
+ const changed = { ...node, metadata: { title: "新标题" } };
+ const added = JSON.parse(JSON.stringify(buildCanvasOperations(canvas({ nodes: [node] }), canvas({ nodes: [changed] }))));
+ expect(added).toEqual([{ type: "patch_node", id: "node", patch: { "metadata.title": "新标题" }, before: { "metadata.title": null } }]);
+ const removed = JSON.parse(JSON.stringify(buildCanvasOperations(canvas({ nodes: [changed] }), canvas({ nodes: [node] }))));
+ expect(removed).toEqual([{ type: "patch_node", id: "node", patch: { "metadata.title": null }, before: { "metadata.title": "新标题" } }]);
+ expect(buildCanvasOperations(canvas({ nodes: [node] }), canvas({ nodes: [{ ...node, metadata: {} }] }))).toEqual([]);
+ });
+
+ test("旧 journal 的空节点补丁从原快照恢复,保留 mutationId 和有效请求", () => {
+ const before = canvas({ nodes: [{ id: "node", metadata: {} }] as CanvasProject["nodes"] });
+ const after = canvas({ nodes: [{ id: "node", metadata: { title: "新标题" } }] as CanvasProject["nodes"] });
+ const broken = { mutationId: "node-mutation", operations: [{ type: "patch_node", id: "node", patch: { "metadata.title": "新标题" }, before: {} }] } as Parameters[0];
+ const repaired = repairPendingCanvasMutationRequest(broken, before, after);
+ expect(repaired.mutationId).toBe("node-mutation");
+ expect(repaired.operations).toEqual(buildCanvasOperations(before, after));
+ expect(repairPendingCanvasMutationRequest(repaired, before, after)).toBe(repaired);
+ expect(repairPendingCanvasMutationRequest(broken)).toBe(broken);
+
+ const deletion = { mutationId: "delete-field", operations: [{ type: "patch_node", id: "node", patch: {}, before: { "metadata.title": "新标题" } }] } as Parameters[0];
+ expect(repairPendingCanvasMutationRequest(deletion, after, before).operations).toEqual(buildCanvasOperations(after, before));
+ const noop = { ...broken, mutationId: "noop", operations: [{ ...broken.operations[0], patch: {} }] } as Parameters[0];
+ expect(repairPendingCanvasMutationRequest(noop, before, before).operations).toEqual([]);
+ });
+ test("新画布只发 create_canvas,不追加会与创建字段冲突的 set_field", () => {
+ const operations = buildCanvasOperations(undefined, canvas());
+ expect(operations[0]?.type).toBe("create_canvas");
+ expect(operations.some((operation) => operation.type === "set_field")).toBe(false);
+ });
+
+ test("已有画布改标题时带上 beforeValue", () => {
+ const operations = buildCanvasOperations(canvas({ title: "旧" }), canvas({ title: "新" }));
+ expect(operations).toEqual([{ type: "set_field", field: "title", value: "新", beforeValue: "旧" }]);
+ });
+
+ test("修复旧 journal:移除新画布中重复的 set_field,保留 mutationId", () => {
+ const legacy = JSON.parse(
+ JSON.stringify({
+ mutationId: "m-1",
+ operations: [
+ { type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } },
+ { type: "set_field", field: "title", value: "自由画布 1", beforeValue: undefined },
+ ],
+ }),
+ );
+ const repaired = repairPendingCanvasMutationRequest(legacy, undefined, canvas());
+ expect(repaired.mutationId).toBe("m-1");
+ expect(repaired.operations).toEqual([{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } }]);
+ });
+
+ test("修复旧 journal:移除空 projectId 到缺省值的语义 no-op", () => {
+ const legacy = {
+ mutationId: "m-3",
+ operations: [
+ { type: "create_node", node: { id: "node-1" } },
+ { type: "set_field", field: "projectId", value: null, beforeValue: "" },
+ ],
+ } as never;
+ const repaired = repairPendingCanvasMutationRequest(legacy, canvas({ projectId: "" }), canvas({ projectId: undefined }));
+ expect(repaired.mutationId).toBe("m-3");
+ expect(repaired.operations).toEqual([{ type: "create_node", node: { id: "node-1" } }]);
+ });
+ test("修复旧 journal:缺失的可选字段基线编码为 null", () => {
+ const legacy = JSON.parse(JSON.stringify({ mutationId: "m-2", operations: [{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: undefined }] }));
+ const repaired = repairPendingCanvasMutationRequest(legacy, canvas(), canvas());
+ expect(repaired.operations).toEqual([{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: null }]);
+ });
+});
diff --git a/web/test/canvas-persistence-loss.test.ts b/web/test/canvas-persistence-loss.test.ts
new file mode 100644
index 000000000..839941f82
--- /dev/null
+++ b/web/test/canvas-persistence-loss.test.ts
@@ -0,0 +1,112 @@
+import { expect, test } from "bun:test";
+
+import { rebaseCanvasProjects, parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
+import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
+import { buildCanvasOperations, mergeRemoteAssetsWithLocalPending, remoteUserDataRetryDelay } from "@/services/user-data-sync";
+import type { Asset } from "@/stores/use-asset-store";
+import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
+import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
+
+const node = (id: string, title = id): CanvasNodeData => ({
+ id,
+ type: CanvasNodeType.Image,
+ title,
+ position: { x: 0, y: 0 },
+ width: 100,
+ height: 100,
+ metadata: { content: title },
+});
+
+const project = (nodes: CanvasNodeData[], revision = 1): CanvasProject => ({
+ id: "canvas",
+ revision,
+ title: "Canvas",
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ nodes,
+ connections: [],
+ chatSessions: [],
+ activeChatId: null,
+ backgroundMode: "dots",
+ showImageInfo: false,
+ viewport: { x: 0, y: 0, k: 1 },
+ previsScenes: [],
+});
+
+test("storage rebase keeps durable-only and local-only nodes when project revisions differ", () => {
+ const base = project([node("base")], 1);
+ const local = project([node("base"), node("local")], 1);
+ const durable = project([node("base"), node("remote")], 2);
+ const document = parseCanvasStorageDocument(null, [durable]);
+
+ const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
+ const result = rebased.document.state.projects[0]!;
+
+ expect(result.nodes.map((item) => item.id).sort()).toEqual(["base", "local", "remote"]);
+});
+
+test("storage rebase preserves a local node recreated after a concurrent deletion", () => {
+ const base = project([node("same", "before")]);
+ const local = project([node("same", "recreated")]);
+ const document = parseCanvasStorageDocument(null, [project([], 2)]);
+ document.tombstones.nodes.canvas = { same: 2 };
+
+ const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
+ const result = rebased.document.state.projects[0]!;
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]).toMatchObject({ id: "same", title: "recreated" });
+ expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true);
+});
+
+test("storage rebase keeps a durable edit when a stale local delete races with it", () => {
+ const base = project([node("same", "before")]);
+ const local = project([]);
+ const durable = project([node("same", "remote edit")], 2);
+ const document = parseCanvasStorageDocument(null, [durable]);
+
+ const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
+ const result = rebased.document.state.projects[0]!;
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]).toMatchObject({ id: "same", title: "remote edit" });
+ expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true);
+});
+
+test("generation result refreshes an existing additional node without losing its local presentation edits", () => {
+ const before = node("root", "Root");
+ const child = { ...node("child", "Renamed"), position: { x: 900, y: 700 }, metadata: { content: "old" } };
+ const result = { ...before, metadata: { content: "root-result" } };
+ const refreshedChild = { ...child, metadata: { content: "new" } };
+
+ const committed = commitCanvasGenerationResult([before, child], before, result, "task", [refreshedChild]);
+
+ expect(committed).toHaveLength(2);
+ expect(committed[1]).toMatchObject({ id: "child", title: "Renamed", position: { x: 900, y: 700 }, metadata: { content: "new" } });
+});
+
+test("login keeps local asset edits and new assets while retaining remote assets", () => {
+ const remoteAsset = { id: "shared", kind: "text", title: "remote", coverUrl: "", tags: [], createdAt: "2026-01-01", updatedAt: "2026-01-02", data: { content: "remote" } } as Asset;
+ const localEdit = { ...remoteAsset, title: "local edit", updatedAt: "2026-01-03" };
+ const localNew = { ...remoteAsset, id: "local-only", title: "local only" };
+ const merged = mergeRemoteAssetsWithLocalPending([remoteAsset], [localEdit, localNew]);
+
+ expect(merged).toHaveLength(2);
+ expect(merged.find((asset) => asset.id === "shared")?.title).toBe("local edit");
+ expect(merged.find((asset) => asset.id === "local-only")?.title).toBe("local only");
+});
+
+test("remote sync retry keeps polling at the capped backoff instead of stopping", () => {
+ expect(remoteUserDataRetryDelay(0)).toBe(2_000);
+ expect(remoteUserDataRetryDelay(4)).toBe(30_000);
+ expect(remoteUserDataRetryDelay(20)).toBe(30_000);
+ expect(remoteUserDataRetryDelay(20, 45_000)).toBe(45_000);
+});
+
+test("new revision-zero canvas produces an explicit create mutation instead of an implicit update", () => {
+ const draft = project([node("local")], 0);
+ const operations = buildCanvasOperations(undefined, draft);
+
+ expect(operations[0]).toMatchObject({ type: "create_canvas", id: "canvas" });
+ expect(operations.some((operation) => operation.type === "create_node")).toBe(true);
+});
diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts
deleted file mode 100644
index 1386ba70f..000000000
--- a/web/test/canvas-remote-revision.test.ts
+++ /dev/null
@@ -1,531 +0,0 @@
-import { afterEach, beforeEach, expect, test } from "bun:test";
-import localforage from "localforage";
-import { apiClient } from "../src/services/api/request";
-import { canvasContentHash } from "../src/lib/canvas/canvas-content";
-import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
-import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
-import {
- applyAgentCanvasPatches,
- deleteCanvasProjectsWithRemoteSync,
- refreshCanvasAfterAgent,
- initializeRemoteUserDataSession,
- installRemoteUserDataAutoSync,
- loadCanvasProjectForEditing,
- resetRemoteUserDataSync,
- saveRemoteUserDataNow,
- syncRemoteUserData,
- overwriteRemoteCanvasProject,
-} from "../src/services/user-data-sync";
-import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
-import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
-import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
-import { useSyncProgressStore } from "../src/stores/use-sync-progress-store";
-import { CanvasNodeType } from "../src/types/canvas";
-
-const originalWindow = globalThis.window;
-const originalAdapter = apiClient.defaults.adapter;
-const originalGet = localforage.getItem;
-const originalSet = localforage.setItem;
-const indexed = new Map();
-let scope = "";
-let sequence = 0;
-let autoSave: (() => void) | undefined;
-let remote = new Map();
-let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
-let beforePut: (() => Promise) | undefined;
-let deleteFailureId: string | undefined;
-let temporaryPutFailures = 0;
-
-function canvas(id = "canvas"): CanvasProject {
- return {
- id,
- revision: 1,
- title: id,
- createdAt: "2026-09-01T00:00:00Z",
- updatedAt: "2026-09-01T00:00:00Z",
- nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }],
- connections: [],
- chatSessions: [],
- activeChatId: null,
- backgroundMode: "dots",
- showImageInfo: false,
- viewport: { x: 0, y: 0, k: 1 },
- directorScenes: [],
- };
-}
-
-function addNode(project: CanvasProject, id: string) {
- return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] };
-}
-
-beforeEach(async () => {
- resetRemoteUserDataSync();
- scope = `canvas-revision-test-${++sequence}`;
- indexed.clear();
- requests = [];
- autoSave = undefined;
- beforePut = undefined;
- deleteFailureId = undefined;
- temporaryPutFailures = 0;
- remote = new Map([
- ["canvas", canvas()],
- ["other", canvas("other")],
- ]);
- Object.defineProperty(globalThis, "window", {
- configurable: true,
- value: {
- setTimeout: (callback: () => void) => {
- autoSave = callback;
- return 1;
- },
- clearTimeout: () => {
- autoSave = undefined;
- },
- localStorage: { getItem: () => scope, setItem: () => undefined },
- },
- });
- localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
- localforage.setItem = (async (key: string, value: string) => {
- indexed.set(key, value);
- return value;
- }) as typeof localforage.setItem;
- apiClient.defaults.adapter = async (config) => {
- const method = config.method || "get";
- const id = String(config.url).split("/").at(-1)!;
- const body = config.data ? JSON.parse(config.data) : undefined;
- requests.push({ method, id, project: body?.project });
- let data: unknown;
- let status = 200;
- if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] };
- else if (method === "delete") {
- if (id === deleteFailureId) status = 403;
- else {
- remote.delete(id);
- data = { id };
- }
- } else if (id === "batch" && method === "post") {
- data = {
- assets: [
- {
- id: "invalid-mime-asset",
- kind: "image",
- title: "历史素材",
- coverUrl: "",
- tags: [],
- createdAt: "2026-09-01",
- updatedAt: "2026-09-01",
- data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
- },
- ],
- };
- } else if (id === "restore" && method === "post") {
- const current = remote.get("canvas")!;
- if (body.revision !== current.revision) status = 409;
- else {
- const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 };
- remote.set("canvas", saved);
- data = { project: saved };
- }
- } else if (method === "put") {
- await beforePut?.();
- const project = body.project as CanvasProject;
- const current = remote.get(id);
- if (temporaryPutFailures > 0) {
- temporaryPutFailures -= 1;
- status = 503;
- } else if (project.revision !== (current?.revision ?? 0)) status = 409;
- else {
- const saved = { ...structuredClone(project), revision: project.revision! + 1 };
- remote.set(id, saved);
- data = { project: saved };
- }
- } else data = { project: structuredClone(remote.get(id)) };
- return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } };
- };
- useCanvasStore.setState({ projects: [] });
- useAssetStore.setState({ assets: [] });
- installRemoteUserDataAutoSync();
- await syncRemoteUserData(scope);
-});
-
-async function waitForSyncState(predicate: () => boolean) {
- const deadline = Date.now() + 1_000;
- while (!predicate()) {
- if (Date.now() >= deadline) throw new Error("同步测试状态等待超时");
- await new Promise((resolve) => globalThis.setTimeout(resolve, 0));
- }
-}
-
-afterEach(async () => {
- resetRemoteUserDataSync();
- await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
- apiClient.defaults.adapter = originalAdapter;
- localforage.getItem = originalGet;
- localforage.setItem = originalSet;
- if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
- else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
-});
-
-test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => {
- const project = canvas();
- project.nodes[0].metadata = { assetId: "invalid-mime-asset" };
- remote.set(project.id, project);
- await initializeRemoteUserDataSession(scope);
- await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME");
- useCanvasStore.getState().deleteProjects(["other"]);
- await flushCanvasStorePersistence();
- requests = [];
-
- await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]);
-
- expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([
- { method: "delete", id: "canvas" },
- { method: "delete", id: "other" },
- ]);
- expect(remote.size).toBe(0);
- expect(useCanvasStore.getState().projects).toEqual([]);
- await useCanvasStore.persist.rehydrate();
- expect(useCanvasStore.getState().projects).toEqual([]);
-});
-
-test("batch deletion retains failed canvases and persists successful deletions", async () => {
- await initializeRemoteUserDataSession(scope);
- deleteFailureId = "other";
-
- await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow();
-
- expect([...remote.keys()]).toEqual(["other"]);
- expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
- await useCanvasStore.persist.rehydrate();
- expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
-});
-
-test("stale viewport, no-op restore and open do not submit old content", async () => {
- const old = useCanvasStore.getState().openProject("canvas")!;
- remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 });
- useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } });
- useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] });
- expect(autoSave).toBeUndefined();
- expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt);
- await flushCanvasStorePersistence();
- await saveRemoteUserDataNow();
- const latest = await loadCanvasProjectForEditing("canvas");
- expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]);
- expect(latest.revision).toBe(2);
- expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 });
- await saveRemoteUserDataNow();
- expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
-});
-
-test("explicit local overwrite replaces only the selected cloud canvas after a revision conflict", async () => {
- remote.set("canvas", { ...canvas(), title: "cloud edit", revision: 2 });
- useCanvasStore.getState().renameProject("canvas", "local edit");
-
- await overwriteRemoteCanvasProject("canvas");
-
- expect(remote.get("canvas")).toMatchObject({ title: "local edit", revision: 3 });
- expect(remote.get("other")).toMatchObject({ title: "other", revision: 1 });
- expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", cloudRevision: 3, pendingChanges: 0 });
-});
-
-test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => {
- remote.set("canvas", { ...canvas(), revision: 9 });
- await loadCanvasProjectForEditing("canvas", { latest: true });
- useCanvasStore.getState().renameProject("canvas", "已保存的标题");
- await saveRemoteUserDataNow("canvas");
- expect(remote.get("canvas")!.revision).toBe(10);
-
- for (let reload = 0; reload < 3; reload++) {
- resetRemoteUserDataSync();
- await useCanvasStore.persist.rehydrate();
- await initializeRemoteUserDataSession(scope);
- await loadCanvasProjectForEditing("canvas");
- let resolve!: () => void;
- let reject!: (error: unknown) => void;
- const reconciled = new Promise((done, fail) => {
- resolve = done;
- reject = fail;
- });
- const sync = createAgentCanvasSync({
- canvasId: "canvas",
- batchMs: 0,
- applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches),
- refresh: async () => {
- await refreshCanvasAfterAgent("canvas");
- resolve();
- },
- onError: reject,
- });
- try {
- sync.receive({
- eventId: "historical-event",
- runId: "completed-run",
- seq: 1,
- type: "canvas_updated",
- createdAt: "2026-09-17",
- payload: {
- canvasId: "canvas",
- canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] },
- },
- });
- await reconciled;
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
- await saveRemoteUserDataNow("canvas");
- expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes });
- expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
- } finally {
- sync.dispose();
- }
- }
- expect(requests.filter((request) => request.method === "put")).toHaveLength(1);
-});
-
-test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => {
- useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere");
- beforePut = async () => {
- const current = useCanvasStore.getState().openProject("canvas")!;
- remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 });
- };
-
- await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
-
- expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 });
- expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" });
- expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
-});
-
-test("a stale save rebases disjoint Agent edits and keeps saving", async () => {
- remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
- useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
- useCanvasStore.getState().renameProject("other", "other edit");
-
- await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
-
- expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "local-video", "remote-video"]);
- expect(remote.get("canvas")!.revision).toBe(3);
- expect(remote.get("other")!.title).toBe("other edit");
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
-
- useCanvasStore.getState().renameProject("canvas", "more local edits");
- await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
- expect(remote.get("canvas")!.title).toBe("more local edits");
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
-});
-
-test("transient cloud failures schedule a bounded automatic retry", async () => {
- temporaryPutFailures = 1;
- useCanvasStore.getState().renameProject("canvas", "retry me");
- expect(autoSave).toBeDefined();
-
- autoSave?.();
- await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1);
- expect(autoSave).toBeDefined();
- expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试");
-
- autoSave?.();
- await waitForSyncState(() => remote.get("canvas")?.title === "retry me");
- expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2);
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
-});
-
-test("edits during a save retain the right ancestor revision and are saved next", async () => {
- let release!: () => void;
- let started!: () => void;
- const waiting = new Promise((resolve) => {
- release = resolve;
- });
- const entered = new Promise((resolve) => {
- started = resolve;
- });
- beforePut = async () => {
- started();
- await waiting;
- };
- useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes });
- const saving = saveRemoteUserDataNow();
- await entered;
- useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes });
- release();
- await saving;
- expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]);
- expect(remote.get("canvas")!.revision).toBe(3);
- expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]);
- expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!));
- expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true);
-});
-
-test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => {
- useCanvasStore.getState().renameProject("canvas", "unsaved draft");
- await flushCanvasStorePersistence();
- resetRemoteUserDataSync();
- await useCanvasStore.persist.rehydrate();
- await syncRemoteUserData(scope);
- expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas");
- expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft");
- expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]);
- expect(requests.some((request) => request.method === "put")).toBe(false);
-});
-
-test("failed draft storage prevents replacement by the latest server content", async () => {
- const working = localforage.setItem;
- useCanvasStore.getState().renameProject("canvas", "must survive");
- localforage.setItem = (async (key: string, value: unknown) => {
- if (key.includes("sync-drafts")) throw new Error("disk full");
- return working(key, value);
- }) as typeof localforage.setItem;
- await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full");
- expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive");
-});
-
-test("local storage rebase cannot pair old edits with another tab's newer revision", () => {
- const base = canvas();
- const local = addNode(base, "unsaved-local");
- const durable = { ...addNode(base, "remote-video"), revision: 2 };
- const document = parseCanvasStorageDocument(null, [durable]);
- const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0];
- expect(rebased.revision).toBe(1);
- expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local");
-});
-
-test("loading aligns live editor content before publishing its server revision", async () => {
- remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 });
- let applied = false;
- await loadCanvasProjectForEditing("canvas", {
- onLoad: (project) => {
- expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
- expect(project.revision).toBe(2);
- expect(project.nodes.at(-1)!.id).toBe("new-cloud-node");
- applied = true;
- },
- });
- expect(applied).toBe(true);
- expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
-});
-
-test("viewport-only updates during latest load still adopt cloud content", async () => {
- remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
- useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
- const working = localforage.setItem;
- let release!: () => void;
- let started!: () => void;
- const waiting = new Promise((resolve) => {
- release = resolve;
- });
- const entered = new Promise((resolve) => {
- started = resolve;
- });
- localforage.setItem = (async (key: string, value: unknown) => {
- if (key.includes("sync-drafts:")) {
- started();
- await waiting;
- }
- return working(key, value);
- }) as typeof localforage.setItem;
- const loading = loadCanvasProjectForEditing("canvas", { latest: true });
- await entered;
- useCanvasStore.getState().updateProject("canvas", { viewport: { x: 40, y: 12, k: 1.5 } });
- release();
- const loaded = await loading;
- expect(loaded.nodes.at(-1)!.id).toBe("remote-video");
- expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
- expect(useCanvasStore.getState().openProject("canvas")!.viewport).toEqual({ x: 40, y: 12, k: 1.5 });
-});
-
-test("edits arriving during draft backup prevent replacement of the live canvas", async () => {
- useCanvasStore.getState().renameProject("canvas", "first local edit");
- const working = localforage.setItem;
- let release!: () => void;
- let started!: () => void;
- const waiting = new Promise((resolve) => {
- release = resolve;
- });
- const entered = new Promise((resolve) => {
- started = resolve;
- });
- localforage.setItem = (async (key: string, value: unknown) => {
- if (key.includes("sync-drafts:")) {
- started();
- await waiting;
- }
- return working(key, value);
- }) as typeof localforage.setItem;
- const loading = loadCanvasProjectForEditing("canvas", { latest: true });
- await entered;
- useCanvasStore.getState().renameProject("canvas", "second local edit");
- release();
- await expect(loading).rejects.toThrow("画布仍在更新");
- expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit");
-});
-
-test("unknown revision cannot borrow the latest revision to submit stale content", async () => {
- const local = useCanvasStore.getState().openProject("canvas")!;
- useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) }));
- await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本");
- expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict");
- expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft");
-});
-
-test("HTTP environments without Web Crypto still distinguish unsaved content", async () => {
- const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
- Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
- try {
- const baseline = await canvasContentHash(canvas());
- expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } }));
- expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video")));
- } finally {
- Object.defineProperty(globalThis, "crypto", descriptor);
- }
-});
-
-test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => {
- useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes });
- let applied = false;
- const restored = await loadCanvasProjectForEditing("canvas", {
- historyRestore: { snapshotId: "historical", revision: 1 },
- onLoad: (project) => {
- expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
- expect(project.revision).toBe(2);
- applied = true;
- },
- });
- expect(applied).toBe(true);
- expect(restored.title).toBe("historical content");
- expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true);
- await saveRemoteUserDataNow("canvas");
- expect(requests.filter((request) => request.method === "post")).toHaveLength(1);
- expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
-});
-
-test("a stale history restore preserves both current cloud content and local edits", async () => {
- remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 });
- useCanvasStore.getState().renameProject("canvas", "local draft");
- await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突");
- expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video");
- expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft");
- expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft");
-});
-
-test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => {
- useCanvasStore.getState().renameProject("canvas", "offline draft");
- await flushCanvasStorePersistence();
- await initializeRemoteUserDataSession(scope);
- const loaded = await loadCanvasProjectForEditing("canvas");
- expect(loaded.title).toBe("offline draft");
- expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending");
- expect(remote.get("canvas")!.title).toBe("canvas");
- await saveRemoteUserDataNow("canvas");
- expect(remote.get("canvas")!.title).toBe("offline draft");
- expect(remote.get("canvas")!.revision).toBe(2);
-});
-
-test("failed draft backup prevents a history restore request", async () => {
- const working = localforage.setItem;
- localforage.setItem = (async (key: string, value: unknown) => {
- if (key.includes("sync-drafts:")) throw new Error("disk full");
- return working(key, value);
- }) as typeof localforage.setItem;
- await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full");
- expect(requests.some((request) => request.method === "post")).toBe(false);
- expect(remote.get("canvas")!.revision).toBe(1);
-});
diff --git a/web/test/canvas-storyboard-context.test.ts b/web/test/canvas-storyboard-context.test.ts
index 03033f023..6be116ee3 100644
--- a/web/test/canvas-storyboard-context.test.ts
+++ b/web/test/canvas-storyboard-context.test.ts
@@ -20,6 +20,12 @@ const styleNode = node("style", CanvasNodeType.Text, {
});
describe("storyboard generation context", () => {
+ it("does not require a project style node", () => {
+ const context = resolveStoryboardGenerationContext([]);
+
+ expect(context.characters).toEqual([]);
+ });
+
it("ignores standalone character-design workflows without a linked asset", () => {
const context = resolveStoryboardGenerationContext([
styleNode,
diff --git a/web/test/canvas-storyboard-output-contract.test.ts b/web/test/canvas-storyboard-output-contract.test.ts
index a7e5e9525..e2e27344f 100644
--- a/web/test/canvas-storyboard-output-contract.test.ts
+++ b/web/test/canvas-storyboard-output-contract.test.ts
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test";
-import { withStoryboardOutputContract } from "../src/lib/canvas/canvas-project-domain";
+import { stripProjectStyleExecutionBlocks, withStoryboardOutputContract } from "../src/lib/canvas/canvas-project-domain";
+
+describe("stripProjectStyleExecutionBlocks", () => {
+ test("removes repeated legacy style suffixes while preserving the original story", () => {
+ const prompt = "@图片1 冬天\n\n【项目画风执行规范】\n旧风格规范\n\n【项目画风执行规范】\n重复旧风格规范";
+
+ expect(stripProjectStyleExecutionBlocks(prompt)).toBe("@图片1 冬天");
+ });
+});
// 画布「分镜脚本」节点与章节分镜都把用户输入原样交给模型,拆镜要求与 JSON 契约只能由调用方显式拼上:
// 漏掉契约时模型按自然语言习惯回复(Markdown 表格、甚至续写正文),storyboardRowsFromTask 便报
diff --git a/web/test/canvas-storyboard-panel.test.ts b/web/test/canvas-storyboard-panel.test.ts
index c5ad42c0d..a580da272 100644
--- a/web/test/canvas-storyboard-panel.test.ts
+++ b/web/test/canvas-storyboard-panel.test.ts
@@ -28,4 +28,10 @@ describe("storyboard prompt panel boundaries", () => {
expect(project).toContain("onOpen={() => setScriptEditorNodeId(contentNode.id)}");
expect(project).toContain("onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)}");
});
+
+ test("storyboard generation strips legacy style instructions without sending project style", () => {
+ const storyboard = source("use-canvas-storyboard.ts");
+ expect(storyboard).toContain("stripProjectStyleExecutionBlocks(prompt)");
+ expect(storyboard).not.toContain("projectStyle: storyboardContext.projectStyle");
+ });
});
diff --git a/web/test/channel-model-import-selection.test.ts b/web/test/channel-model-import-selection.test.ts
new file mode 100644
index 000000000..b9c6fe52b
--- /dev/null
+++ b/web/test/channel-model-import-selection.test.ts
@@ -0,0 +1,52 @@
+import { expect, test } from "bun:test";
+
+import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "../src/pages/admin/components/channel-model-import-selection";
+
+const MODELS = [
+ "MiniMax-H3",
+ "004系列/MiniMax-H3(933/10-15秒768P)",
+ "004系列/sd2.5(30--0-10卡脸4-30秒/720P)",
+ "gpt-image-1",
+ "doubao-seedream-4",
+];
+
+test("关键字为空时返回全部模型", () => {
+ expect(filterImportModels(MODELS, "")).toEqual(MODELS);
+ expect(filterImportModels(MODELS, " ")).toEqual(MODELS);
+});
+
+test("按子串匹配,且大小写不敏感", () => {
+ expect(filterImportModels(MODELS, "minimax")).toEqual([MODELS[0], MODELS[1]]);
+ expect(filterImportModels(MODELS, "MINIMAX")).toEqual([MODELS[0], MODELS[1]]);
+});
+
+test("关键字首尾空白不影响匹配", () => {
+ expect(filterImportModels(MODELS, " sd2.5 ")).toEqual([MODELS[2]]);
+});
+
+test("没有命中时返回空数组(弹窗据此显示空态)", () => {
+ expect(filterImportModels(MODELS, "不存在的模型")).toEqual([]);
+});
+
+test("全选只并入当前可见集合,关键字外的勾选保留", () => {
+ const visible = filterImportModels(MODELS, "minimax");
+ expect(selectVisibleModels(["gpt-image-1"], visible)).toEqual(["gpt-image-1", MODELS[0], MODELS[1]]);
+});
+
+test("全选不会产生重复项", () => {
+ const visible = filterImportModels(MODELS, "minimax");
+ expect(selectVisibleModels([MODELS[0]], visible)).toEqual([MODELS[0], MODELS[1]]);
+});
+
+test("无关键字时全选等于选中全部,与旧行为一致", () => {
+ expect(selectVisibleModels([], filterImportModels(MODELS, ""))).toEqual(MODELS);
+});
+
+test("取消选中只移除可见的,隐藏的勾选不动", () => {
+ const visible = filterImportModels(MODELS, "minimax");
+ expect(deselectVisibleModels([MODELS[0], MODELS[1], "gpt-image-1"], visible)).toEqual(["gpt-image-1"]);
+});
+
+test("无关键字时取消选中等于清空,与旧行为一致", () => {
+ expect(deselectVisibleModels(MODELS, filterImportModels(MODELS, ""))).toEqual([]);
+});
diff --git a/web/test/cloud-agent-plan.test.ts b/web/test/cloud-agent-plan.test.ts
index 8ea7a0827..a7a08f254 100644
--- a/web/test/cloud-agent-plan.test.ts
+++ b/web/test/cloud-agent-plan.test.ts
@@ -40,11 +40,6 @@ describe("latestAgentPlanItems", () => {
});
});
-/**
- * 回归(2026-09-16):运行中输入框是禁用的,模型在正文里提问用户根本答不了
- * (生产实测它连问 4 轮、白烧十几步)。`ask_user` 让本轮收尾并把问题结构化,
- * 这个选择器决定「现在该不该把选项面板亮给用户」。
- */
describe("pendingAgentQuestion", () => {
const question = { question: "选哪个模型?", options: [{ label: "H3文生视频" }, { label: "Seedance 2.5" }] };
diff --git a/web/test/director-diagnostics-recorder.test.ts b/web/test/director-diagnostics-recorder.test.ts
deleted file mode 100644
index f01f0161d..000000000
--- a/web/test/director-diagnostics-recorder.test.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { beforeEach, describe, expect, test } from "bun:test";
-
-import { recordPrevisDiagnostic, resetPrevisDiagnosticDedupe } from "../src/lib/canvas/previs/previs-diagnostics-recorder";
-import { getClientDiagnosticEvents } from "../src/services/diagnostics/client-diagnostics";
-
-/** 只看预演台自己的稳定码事件,避免与其它测试写入的诊断流互相干扰。 */
-function previsEvents() {
- return getClientDiagnosticEvents().filter((event) => (event.code || "").startsWith("PREVIS_"));
-}
-
-function newestPrevisEvent() {
- return previsEvents().at(-1);
-}
-
-const LEAK_PROBES = [
- "https://cdn.example.com/actor.glb?token=abc123#frag",
- "authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig",
- "cookie=session=deadbeef; Path=/",
- "api_key=sk-live-0000",
- "Error: boom\n at PrevisModel (previs-viewport.tsx:571:29)",
- "演员 1 的镜头意图正文",
-];
-
-beforeEach(() => {
- resetPrevisDiagnosticDedupe();
-});
-
-describe("记录进统一诊断缓冲区", () => {
- test("写入的事件带稳定码、固定文案与 runtime 分类", () => {
- const before = previsEvents().length;
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED")).toBe(true);
-
- expect(previsEvents().length).toBe(before + 1);
- const event = newestPrevisEvent();
- expect(event?.code).toBe("PREVIS_VIEWPORT_RENDER_FAILED");
- expect(event?.level).toBe("error");
- expect(event?.category).toBe("runtime");
- expect(event?.message).toBe("预演台 3D 视口渲染失败");
- // 绝不落 stack。
- expect(event?.stack || "").toBe("");
- });
-
- test("安全字段以枚举后缀进入 code,便于检索", () => {
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model", attempt: 2 })).toBe(true);
- const event = newestPrevisEvent();
- expect(event?.code).toBe("PREVIS_MODEL_LOAD_FAILED object=obj-1 kind=model attempt=2");
- });
-
- test("sceneId 只进 code 后缀,不冒充 canvasId", () => {
- expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "repro-scene-1", revision: 3 })).toBe(true);
- const event = newestPrevisEvent();
- // PrevisScene.id 不是 canvas id,绝不写入既有后端契约字段。
- expect(event?.canvasId).toBeUndefined();
- expect(event?.code).toBe("PREVIS_SAVE_FLUSH_FAILED scene=repro-scene-1 revision=3");
- });
-
- test("三个保存相关码都能写入且级别正确", () => {
- recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { revision: 1 });
- expect(newestPrevisEvent()?.level).toBe("info");
- recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { revision: 1 });
- expect(newestPrevisEvent()?.level).toBe("error");
- recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" });
- expect(newestPrevisEvent()?.level).toBe("warning");
- });
-});
-
-describe("去重与噪声控制", () => {
- test("同一 code+字段在窗口内只记一次", () => {
- const before = previsEvents().length;
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false);
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false);
- expect(previsEvents().length).toBe(before + 1);
- });
-
- test("字段不同视为不同事件,不会被误去重", () => {
- const before = previsEvents().length;
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 1 })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 2 })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor", attempt: 1 })).toBe(true);
- expect(previsEvents().length).toBe(before + 3);
- });
-
- test("不同 objectId 在窗口内不互相吞掉", () => {
- const before = previsEvents().length;
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2", objectKind: "model" })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-3", objectKind: "model" })).toBe(true);
- // 同一 objectId 重复仍要被去重。
- expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(false);
- expect(previsEvents().length).toBe(before + 3);
- });
-
- test("不同 sceneId 在窗口内不互相吞掉", () => {
- const before = previsEvents().length;
- expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-2", revision: 1 })).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(false);
- expect(previsEvents().length).toBe(before + 2);
- });
-
- test("所有写入的 code 都以稳定 PREVIS_ 前缀开头", () => {
- resetPrevisDiagnosticDedupe();
- recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: "scene-1", saveOutcome: "stay" });
- for (const event of previsEvents()) {
- expect((event.code || "").startsWith("PREVIS_")).toBe(true);
- }
- });
-
- test("重置去重状态后可再次记录同一事件", () => {
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true);
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(false);
- resetPrevisDiagnosticDedupe();
- expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true);
- });
-});
-
-describe("不泄漏敏感内容", () => {
- test("伪造的敏感字段不会进入缓冲区任何字段", () => {
- for (const probe of LEAK_PROBES) {
- resetPrevisDiagnosticDedupe();
- recordPrevisDiagnostic("PREVIS_MODEL_ADOPT_FAILED", {
- objectId: probe,
- sceneId: probe,
- // @ts-expect-error 故意传入非法枚举,验证运行时白名单而非仅靠类型。
- objectKind: probe,
- // @ts-expect-error 故意传入非法数值类型。
- attempt: probe,
- });
-
- const event = newestPrevisEvent();
- const serialized = JSON.stringify(event);
- expect(serialized).not.toContain("cdn.example.com");
- expect(serialized).not.toContain("token=abc123");
- expect(serialized).not.toContain("sk-live-0000");
- expect(serialized).not.toContain("deadbeef");
- expect(serialized).not.toContain("Bearer");
- expect(serialized).not.toContain("演员");
- expect(serialized).not.toContain("previs-viewport.tsx");
- expect(event?.canvasId).toBeUndefined();
- expect(event?.code).toBe("PREVIS_MODEL_ADOPT_FAILED");
- }
- });
-
- test("未知 code 既不写入也不抛错", () => {
- const before = previsEvents().length;
- // @ts-expect-error 故意传入白名单外的 code。
- expect(recordPrevisDiagnostic("PREVIS_NOT_A_REAL_CODE", { objectId: "obj-1" })).toBe(false);
- expect(previsEvents().length).toBe(before);
- });
-});
diff --git a/web/test/director-diagnostics.test.ts b/web/test/director-diagnostics.test.ts
deleted file mode 100644
index 258748276..000000000
--- a/web/test/director-diagnostics.test.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { describe, expect, test } from "bun:test";
-
-import { PREVIS_DIAGNOSTIC_CODES, previsDiagnosticObjectKind, formatPrevisDiagnosticCode, isPrevisDiagnosticCode, projectPrevisDiagnostic } from "../src/lib/canvas/previs/previs-diagnostics";
-
-/** 任何一个都不允许出现在投影输出里。 */
-const LEAK_PROBES = [
- "https://cdn.example.com/actor.glb?token=abc123",
- "http://localhost:3000/api/tasks?authorization=Bearer%20xyz",
- "Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature",
- "authorization: Bearer secret-token",
- "cookie=session=deadbeef; Path=/",
- "api_key=sk-live-0000",
- "Error: adopt failed\n at PrevisModel (previs-viewport.tsx:571:29)",
- "演员 1 的镜头意图正文",
- "/Users/someone/secret/path/scene.glb",
-];
-
-function serialize(value: unknown) {
- return JSON.stringify(value ?? {});
-}
-
-describe("code 白名单", () => {
- test("白名单内的 code 全部被接受且有稳定文案与级别", () => {
- for (const code of PREVIS_DIAGNOSTIC_CODES) {
- expect(isPrevisDiagnosticCode(code)).toBe(true);
- const event = projectPrevisDiagnostic(code, {});
- expect(event).not.toBeNull();
- expect(event?.code).toBe(code);
- expect(event?.message.length).toBeGreaterThan(0);
- expect(["info", "warning", "error"]).toContain(event?.level ?? "");
- }
- });
-
- test("未知 code 一律拒绝,不透传", () => {
- const rejected = ["", "previs_viewport_render_failed", "PREVIS_UNKNOWN", "SOME_OTHER_CODE", "PREVIS_VIEWPORT_RENDER_FAILED ", null, undefined, 42, {}, []];
- for (const code of rejected) {
- expect(isPrevisDiagnosticCode(code)).toBe(false);
- expect(projectPrevisDiagnostic(code, {})).toBeNull();
- }
- });
-});
-
-describe("字段白名单", () => {
- test("未知字段不会出现在输出中", () => {
- const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", {
- objectId: "obj-1",
- sceneName: "演员 1 场景",
- url: "https://cdn.example.com/a.glb",
- stack: "Error: boom\n at x",
- prompt: "镜头意图正文",
- storageKey: "assets/secret.glb",
- token: "sk-live-0000",
- });
-
- expect(event?.fields).toEqual({ objectId: "obj-1" });
- expect(serialize(event)).not.toContain("cdn.example.com");
- expect(serialize(event)).not.toContain("镜头意图正文");
- expect(serialize(event)).not.toContain("sk-live-0000");
- });
-
- test("伪造的敏感输入无法通过任一字段进入输出", () => {
- for (const probe of LEAK_PROBES) {
- const event = projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", {
- objectId: probe,
- sceneId: probe,
- objectKind: probe,
- saveOutcome: probe,
- attempt: probe,
- revision: probe,
- draftStored: probe,
- userInitiated: probe,
- });
-
- expect(event).not.toBeNull();
- // 所有字段都应被拒绝:probe 既不是 safe-id,也不在枚举内,也不是数值/布尔。
- expect(event?.fields).toEqual({});
- const text = serialize(event);
- expect(text).not.toContain("http");
- expect(text).not.toContain("token");
- expect(text).not.toContain("cookie");
- expect(text).not.toContain("Bearer");
- expect(text).not.toContain("演员");
- }
- });
-
- test("safe-id 只接受受限字符集", () => {
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1_a.b:c" })?.fields.objectId).toBe("obj-1_a.b:c");
- for (const bad of ["obj 1", "obj/1", "obj?1", "obj#1", "a".repeat(97), "", " ", "obj\n1", "obj%201"]) {
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad })?.fields.objectId).toBeUndefined();
- }
- });
-
- test("枚举字段只接受白名单值", () => {
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor" })?.fields.objectKind).toBe("actor");
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "spaceship" })?.fields.objectKind).toBeUndefined();
- expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" })?.fields.saveOutcome).toBe("stay");
- expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "explode" })?.fields.saveOutcome).toBeUndefined();
- });
-
- test("数值字段有界且拒绝非有限值", () => {
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 3 })?.fields.attempt).toBe(3);
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 2.6 })?.fields.attempt).toBe(3);
- for (const bad of [-1, 1000, Number.NaN, Number.POSITIVE_INFINITY, "3", null]) {
- expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: bad })?.fields.attempt).toBeUndefined();
- }
- expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: 0 })?.fields.revision).toBe(0);
- expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: -1 })?.fields.revision).toBeUndefined();
- });
-
- test("布尔字段只接受真布尔", () => {
- expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: false })?.fields.draftStored).toBe(false);
- expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: true })?.fields.draftStored).toBe(true);
- for (const bad of ["true", 1, 0, null, {}]) {
- expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: bad })?.fields.draftStored).toBeUndefined();
- }
- });
-
- test("非对象 fields 不抛错且产出空字段", () => {
- for (const fields of [null, undefined, 42, "objectId=1", [1, 2, 3]]) {
- const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST", fields);
- expect(event?.fields).toEqual({});
- }
- });
-});
-
-describe("稳定码格式化", () => {
- test("只拼接安全枚举与数值", () => {
- const event = projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: "scene-1", revision: 4, draftStored: true, userInitiated: true });
- expect(event).not.toBeNull();
- if (!event) return;
- const formatted = formatPrevisDiagnosticCode(event);
- expect(formatted).toBe("PREVIS_SAVE_RETRY_FAILED scene=scene-1 revision=4 draft=1 user=1");
- // scene/object 后缀只可能是 safe-id 投影后的值。
- expect(formatted.startsWith("PREVIS_")).toBe(true);
- });
-
- test("scene 与 object 后缀都进入 code,顺序确定", () => {
- const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1", objectId: "obj-9", objectKind: "model", attempt: 2 });
- expect(event).not.toBeNull();
- if (!event) return;
- expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_MODEL_LOAD_FAILED scene=scene-1 object=obj-9 kind=model attempt=2");
- });
-
- test("不同 objectId / sceneId 产出不同签名", () => {
- const a = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1" });
- const b = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2" });
- const c = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1" });
- const d = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-2" });
- expect(a && b && c && d).toBeTruthy();
- if (!a || !b || !c || !d) return;
- const signatures = [a, b, c, d].map(formatPrevisDiagnosticCode);
- expect(new Set(signatures).size).toBe(4);
- });
-
- test("非法 id 不进入 code 后缀", () => {
- for (const bad of ["https://cdn.example.com/a.glb?token=abc", "obj 1", "obj/1", "a".repeat(97)]) {
- const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad, sceneId: bad });
- expect(event).not.toBeNull();
- if (!event) continue;
- const formatted = formatPrevisDiagnosticCode(event);
- expect(formatted).toBe("PREVIS_MODEL_LOAD_FAILED");
- expect(formatted).not.toContain("scene=");
- expect(formatted).not.toContain("object=");
- expect(formatted).not.toContain("cdn.example.com");
- }
- });
-
- test("无字段时只有 code 本身", () => {
- const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED", {});
- expect(event).not.toBeNull();
- if (!event) return;
- expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_VIEWPORT_RENDER_FAILED");
- });
-});
-
-describe("对象种类推导", () => {
- test("按形态映射,不读取名称或地址", () => {
- expect(previsDiagnosticObjectKind({ kind: "actor" })).toBe("actor");
- expect(previsDiagnosticObjectKind({ kind: "model" })).toBe("model");
- expect(previsDiagnosticObjectKind({ kind: "billboard" })).toBe("billboard");
- expect(previsDiagnosticObjectKind({ primitive: "box" })).toBe("primitive");
- expect(previsDiagnosticObjectKind({})).toBe("unknown");
- expect(previsDiagnosticObjectKind(null)).toBe("unknown");
- expect(previsDiagnosticObjectKind(undefined)).toBe("unknown");
- });
-});
diff --git a/web/test/director-save-coordinator.test.ts b/web/test/director-save-coordinator.test.ts
deleted file mode 100644
index 6ec8259a7..000000000
--- a/web/test/director-save-coordinator.test.ts
+++ /dev/null
@@ -1,995 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import { scopedStorageKey } from "../src/lib/user-scope";
-import type { PrevisScene } from "../src/types/previs";
-import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisFlushRequest, type FlushFunction, type StorageLike } from "../src/lib/canvas/previs/previs-save";
-
-class MemoryStorage implements StorageLike {
- private readonly values = new Map();
- throwOnGet = false;
- throwOnSet = false;
- throwOnRemove = false;
-
- getItem(key: string): string | null {
- if (this.throwOnGet) throw new Error("get failed");
- return this.values.get(key) ?? null;
- }
-
- setItem(key: string, value: string): void {
- if (this.throwOnSet) throw new Error("set failed");
- this.values.set(key, value);
- }
-
- removeItem(key: string): void {
- if (this.throwOnRemove) throw new Error("remove failed");
- this.values.delete(key);
- }
-
- keys(): string[] {
- return [...this.values.keys()];
- }
-}
-
-class ManualScheduler {
- private callbacks = new Map void>();
- private id = 0;
-
- schedule(callback: () => void, delay: number): unknown {
- const handle = this.id++;
- this.callbacks.set(handle, callback);
- return handle;
- }
-
- cancel(handle: unknown): void {
- this.callbacks.delete(handle as number);
- }
-
- runAll(): void {
- for (const cb of this.callbacks.values()) {
- cb();
- }
- this.callbacks.clear();
- }
-
- size(): number {
- return this.callbacks.size;
- }
-}
-
-function deferred() {
- let res: (() => void) | null = null;
- let rej: ((err: unknown) => void) | null = null;
- const p = new Promise((resolve, reject) => {
- res = resolve;
- rej = reject;
- });
- return {
- promise: p,
- resolve: () => res?.(),
- reject: (err: unknown) => rej?.(err),
- };
-}
-
-const makeScene = (overrides: Partial = {}): PrevisScene => ({
- id: "scene-1",
- version: 1,
- title: "Test Scene",
- background: "#000",
- environmentIntensity: 0.5,
- gridVisible: true,
- objects: [],
- cameras: [],
- lights: [],
- shots: [],
- activeShotId: "shot-1",
- createdAt: "2025-01-01T00:00:00Z",
- updatedAt: "2025-01-01T00:00:00Z",
- ...overrides,
-});
-
-const makeEnvelope = (overrides: Partial = {}): PrevisDraftEnvelope => {
- const scene = overrides.scene ?? makeScene();
- return {
- schemaVersion: 1,
- sceneId: scene.id,
- baseUpdatedAt: scene.updatedAt,
- revision: 1,
- savedAt: 1000,
- ...overrides,
- scene,
- };
-};
-
-/** Serializes a complete valid envelope after breaking only the target fields. */
-const corruptEnvelope = (corrupt: (envelope: Record) => void, overrides: Partial = {}): string => {
- const envelope: Record = {
- ...makeEnvelope(overrides),
- };
- corrupt(envelope);
- return JSON.stringify(envelope);
-};
-
-/**
- * Serializes a complete valid envelope, splicing raw JSON number tokens that
- * JSON.stringify cannot express (for example 1e999 parsing back as Infinity).
- */
-const rawEnvelopeWith = (rawFields: Record): string => {
- const envelope: Record = { ...makeEnvelope() };
- const entries = Object.entries(envelope)
- .filter(([field]) => !(field in rawFields))
- .map(([field, value]) => JSON.stringify(field) + ":" + JSON.stringify(value));
- const rawEntries = Object.entries(rawFields).map(([field, token]) => JSON.stringify(field) + ":" + token);
- return "{" + [...entries, ...rawEntries].join(",") + "}";
-};
-
-type HarnessOptions = {
- scope?: string;
- storage?: MemoryStorage;
- initialScene?: PrevisScene;
-};
-
-const createHarness = (options: HarnessOptions = {}) => {
- const scope = options.scope ?? "user-a";
- const storage = options.storage ?? new MemoryStorage();
- const initialScene = options.initialScene ?? makeScene();
- const scheduler = new ManualScheduler();
- let now = 1000;
- const requests: PrevisFlushRequest[] = [];
- let flushBehavior: FlushFunction = async () => undefined;
-
- const flushFn: FlushFunction = async (request) => {
- requests.push(structuredClone(request));
- await flushBehavior(request);
- };
-
- const coord = createPrevisSaveCoordinator({
- initialScene,
- scope,
- storage,
- flush: flushFn,
- now: () => now++,
- debounceMs: 25,
- schedule: (callback) => scheduler.schedule(callback, 0),
- cancelSchedule: (handle) => scheduler.cancel(handle),
- });
-
- const key = scopedStorageKey("previs-scene-draft:" + initialScene.id, scope);
-
- const editedScene = (overrides: Partial = {}) => {
- const scene = makeScene(overrides);
- coord.edit(scene);
- return scene;
- };
-
- const readEnvelope = (id: string = initialScene.id): PrevisDraftEnvelope | null => {
- const readKey = id === initialScene.id ? key : scopedStorageKey("previs-scene-draft:" + id, scope);
- const raw = storage.getItem(readKey);
- return raw === null ? null : (JSON.parse(raw) as PrevisDraftEnvelope);
- };
-
- const writeRaw = (raw: string): void => {
- storage.setItem(key, raw);
- };
-
- const setFlushBehavior = (next: FlushFunction): void => {
- flushBehavior = next;
- };
-
- const flushMicrotasks = async (): Promise => {
- scheduler.runAll();
- await Promise.resolve();
- await Promise.resolve();
- };
-
- const dispose = (): void => {
- coord.dispose();
- scheduler.runAll();
- };
-
- return {
- storage,
- scheduler,
- now: () => now,
- requests,
- coord,
- scope,
- key,
- editedScene,
- readEnvelope,
- writeRaw,
- setFlushBehavior,
- flushMicrotasks,
- dispose,
- };
-};
-
-describe("PrevisSaveCoordinator", () => {
- describe("01 initial", () => {
- it("should be saved with revision 0, no draft, no recovery", () => {
- const h = createHarness();
- const snap = h.coord.getSnapshot();
- expect(snap.status).toBe("saved");
- expect(snap.revision).toBe(0);
- expect(snap.confirmedRevision).toBe(0);
- expect(snap.draftStored).toBe(false);
- expect(h.readEnvelope("scene-1")).toBeNull();
- });
- });
-
- describe("02 edit", () => {
- it("should write to fixed scoped key with complete envelope", () => {
- const h = createHarness();
- const s = h.editedScene({ title: "Edited" });
- expect(s).toBeDefined();
- const env = h.readEnvelope("scene-1");
- expect(env).toMatchObject({
- schemaVersion: 1,
- sceneId: "scene-1",
- scene: expect.objectContaining({ title: "Edited" }),
- });
- expect(env.revision).toBe(1);
- expect(env.savedAt).toBeDefined();
- });
- });
-
- describe("03 continuous edit", () => {
- it("should monotonically increase revision and reflect latest scene", () => {
- const h = createHarness();
- h.editedScene({ title: "v1" });
- h.editedScene({ title: "v2" });
- const snap = h.coord.getSnapshot();
- expect(snap.revision).toBe(2);
- expect(snap.status).toBe("dirty");
- });
- });
-
- describe("04 invalid scene id", () => {
- it("should reject edit with wrong scene id and leave status unchanged", () => {
- const h = createHarness();
- const originalStatus = h.coord.getSnapshot().status;
- const result = h.coord.edit(makeScene({ id: "wrong" }));
- expect(result).toBe(false);
- expect(h.coord.getSnapshot().status).toBe(originalStatus);
- });
- });
-
- describe("05 storage set fail", () => {
- it("should keep memory edit, set error status, draftStored false", () => {
- const h = createHarness();
- h.storage.throwOnSet = true;
- const scene = makeScene({ title: "memory-only" });
- expect(h.coord.edit(scene)).toBe(true);
- const snap = h.coord.getSnapshot();
- expect(snap.scene.title).toBe("memory-only");
- expect(snap.status).toBe("error");
- expect(snap.draftStored).toBe(false);
- });
- });
-
- describe("06 debounce", () => {
- it("should debounce multiple edits and flush only the latest scene", async () => {
- const h = createHarness();
- h.editedScene({ title: "a" });
- h.editedScene({ title: "b" });
- expect(h.scheduler.size()).toBe(1);
- await h.flushMicrotasks();
- expect(h.requests).toHaveLength(1);
- expect(h.requests[0]?.scene.title).toBe("b");
- });
- });
-
- describe("07 concurrent flush", () => {
- it("should return the same Promise for concurrent flushLatest calls", async () => {
- const h = createHarness();
- h.editedScene({ title: "dirty" });
- const first = h.coord.flushLatest();
- const second = h.coord.flushLatest();
- expect(first).toBe(second);
- expect(await first).toBe(true);
- });
- });
-
- describe("08 concurrent edit during flush", () => {
- it("should send second request in drain and update remaining draft base", async () => {
- const h = createHarness();
- const first = deferred();
- const second = deferred();
- let flushCall = 0;
- h.setFlushBehavior(async () => {
- const gate = flushCall++ === 0 ? first : second;
- await gate.promise;
- });
-
- const firstScene = h.editedScene({
- title: "first",
- updatedAt: "2026-01-01T00:00:01.000Z",
- });
- const pending = h.coord.flushLatest();
- expect(h.requests).toHaveLength(1);
- expect(h.requests[0]?.scene.title).toBe("first");
-
- h.editedScene({
- title: "second",
- updatedAt: "2026-01-01T00:00:02.000Z",
- });
- first.resolve();
- await h.flushMicrotasks();
-
- expect(h.requests).toHaveLength(2);
- expect(h.requests[1]?.scene.title).toBe("second");
- expect(h.coord.getSnapshot().status).not.toBe("saved");
- expect(h.readEnvelope()?.baseUpdatedAt).toBe(firstScene.updatedAt);
-
- second.resolve();
- expect(await pending).toBe(true);
- expect(h.coord.getSnapshot()).toMatchObject({
- revision: 2,
- confirmedRevision: 2,
- status: "saved",
- });
- expect(h.readEnvelope()).toBeNull();
- });
- });
-
- describe("09 remote fail", () => {
- it("should keep safe draft and set error status", async () => {
- const h = createHarness();
- h.editedScene({ title: "remote-fail" });
- h.setFlushBehavior(async () => {
- throw new Error("remote failed");
- });
- expect(await h.coord.flushLatest()).toBe(false);
- expect(h.requests).toHaveLength(1);
- expect(h.coord.getSnapshot()).toMatchObject({
- status: "error",
- draftStored: true,
- confirmedRevision: 0,
- });
- expect(h.readEnvelope()?.scene.title).toBe("remote-fail");
- });
- });
-
- describe("10 retry", () => {
- it("should re-call remote and clear draft on success", async () => {
- const h = createHarness();
- await h.coord.flushLatest();
- const result = await h.coord.retry();
- expect(result).toBe(true);
- expect(h.readEnvelope("scene-1")).toBeNull();
- });
- });
-
- describe("11 successful flush clears only matching draft", () => {
- it("should clear only draft with confirmed revision", async () => {
- const h = createHarness();
- h.editedScene();
- await h.coord.flushLatest();
- expect(h.readEnvelope("scene-1")).toBeNull();
- });
- });
-
- describe("12 remove fail keeps saved status", () => {
- it("should stay saved but keep the draft when removeItem fails", async () => {
- const h = createHarness();
- h.editedScene({ title: "remove-fail" });
- expect(h.coord.getSnapshot().draftStored).toBe(true);
- expect(h.readEnvelope()?.revision).toBe(1);
-
- h.storage.throwOnRemove = true;
- expect(await h.coord.flushLatest()).toBe(true);
-
- expect(h.coord.getSnapshot()).toMatchObject({
- status: "saved",
- confirmedRevision: 1,
- draftStored: true,
- });
-
- h.storage.throwOnRemove = false;
- expect(h.readEnvelope()?.revision).toBe(1);
- });
- });
-
- describe("13 malformed JSON", () => {
- it("should reject unparsable JSON stored under the scoped key", () => {
- const h = createHarness();
- h.writeRaw("invalid json");
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("14 wrong schema", () => {
- it("should reject a candidate whose schemaVersion is not 1", () => {
- const h = createHarness();
-
- h.writeRaw(JSON.stringify(makeEnvelope()));
- expect(h.coord.restoreCandidate()).not.toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.schemaVersion = 2;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("15 wrong sceneId", () => {
- it("should reject a candidate whose sceneId does not match the scene", () => {
- const h = createHarness();
-
- h.writeRaw(JSON.stringify(makeEnvelope()));
- expect(h.coord.restoreCandidate()).not.toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.sceneId = "scene-other";
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("16 missing baseUpdatedAt", () => {
- it("should reject a candidate whose baseUpdatedAt is missing or not a string", () => {
- const h = createHarness();
-
- h.writeRaw(JSON.stringify(makeEnvelope()));
- expect(h.coord.restoreCandidate()).not.toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- delete envelope.baseUpdatedAt;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.baseUpdatedAt = 20250101;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("17 invalid scene shape", () => {
- it("should reject a candidate whose scene fails PrevisScene validation", () => {
- const h = createHarness();
-
- h.writeRaw(JSON.stringify(makeEnvelope()));
- expect(h.coord.restoreCandidate()).not.toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.scene = { ...makeScene(), gridVisible: "yes" };
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.scene = { ...makeScene(), shots: "none" };
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.scene = makeScene({ id: "scene-2" });
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("18 invalid revision/savedAt", () => {
- it("should reject negative, fractional, unsafe revision and non-finite savedAt", () => {
- const h = createHarness();
-
- h.writeRaw(JSON.stringify(makeEnvelope()));
- expect(h.coord.restoreCandidate()).not.toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.revision = -1;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.revision = 1.5;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(
- corruptEnvelope((envelope) => {
- envelope.revision = Number.MAX_SAFE_INTEGER + 1;
- }),
- );
- expect(h.coord.restoreCandidate()).toBeNull();
-
- h.writeRaw(rawEnvelopeWith({ savedAt: "1e999" }));
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("19 scope isolation", () => {
- it("should only read the draft written under its own user scope", () => {
- const storage = new MemoryStorage();
- const userA = createHarness({ scope: "user-a", storage });
- const userB = createHarness({ scope: "user-b", storage });
- expect(userA.key).not.toBe(userB.key);
-
- userB.writeRaw(
- JSON.stringify(
- makeEnvelope({
- revision: 3,
- scene: makeScene({ title: "user-b draft" }),
- }),
- ),
- );
-
- expect(storage.keys()).toEqual([userB.key]);
- expect(userA.coord.restoreCandidate()).toBeNull();
-
- const candidate = userB.coord.restoreCandidate();
- expect(candidate).not.toBeNull();
- expect(candidate?.revision).toBe(3);
- expect(candidate?.scene.title).toBe("user-b draft");
- });
- });
-
- describe("20 explicitRestore success", () => {
- it("should succeed and set dirty status", () => {
- const h = createHarness();
- const env = {
- schemaVersion: 1,
- sceneId: "scene-1",
- baseUpdatedAt: "2025",
- scene: makeScene(),
- revision: 5,
- savedAt: 100,
- };
- const result = h.coord.explicitRestore(env);
- expect(result).toBe(true);
- expect(h.coord.getSnapshot().status).toBe("dirty");
- expect(h.coord.getSnapshot().revision).toBe(5);
- });
- });
-
- describe("21 invalid explicitRestore", () => {
- it("should reject older or invalid envelope", () => {
- const h = createHarness();
- h.editedScene();
- const env = { ...h.readEnvelope("scene-1")!, revision: 0 };
- const result = h.coord.explicitRestore(env);
- expect(result).toBe(false);
- });
- });
-
- describe("22 clean prepareClose", () => {
- it("should immediately return close when confirmedRevision equals current", async () => {
- const h = createHarness();
- const result = await h.coord.prepareClose();
- expect(result).toBe("close");
- });
- });
-
- describe("23 dirty prepareClose", () => {
- it("should reuse promise and return close on success", async () => {
- const h = createHarness();
- h.editedScene();
- const p1 = h.coord.prepareClose();
- const p2 = h.coord.prepareClose();
- expect(p1).toBe(p2);
- const result = await p1;
- expect(result).toBe("close");
- });
- });
-
- describe("24 prepareClose fail with draft", () => {
- it("should return offer-draft-exit when remote fails but the draft is safe", async () => {
- const h = createHarness();
- h.editedScene({ title: "close-fail" });
- expect(h.coord.getSnapshot().draftStored).toBe(true);
- expect(h.readEnvelope()?.scene.title).toBe("close-fail");
-
- h.setFlushBehavior(async () => {
- throw new Error("remote rejected");
- });
-
- expect(await h.coord.prepareClose()).toBe("offer-draft-exit");
- expect(h.coord.getSnapshot().status).toBe("error");
- expect(h.readEnvelope()?.scene.title).toBe("close-fail");
- });
- });
-
- describe("25 prepareClose full fail", () => {
- it("should return stay when both the local draft and the remote flush fail", async () => {
- const h = createHarness();
- h.storage.throwOnSet = true;
- h.setFlushBehavior(async () => {
- throw new Error("remote rejected");
- });
-
- h.editedScene({ title: "no-draft" });
- expect(h.coord.getSnapshot().draftStored).toBe(false);
-
- expect(await h.coord.prepareClose()).toBe("stay");
- expect(h.coord.getSnapshot().status).toBe("error");
- expect(h.readEnvelope()).toBeNull();
- });
- });
-
- describe("26 handlePageHide", () => {
- it("should reuse one in-flight promise and flush the latest revision", async () => {
- const h = createHarness();
- const gate = deferred();
- h.setFlushBehavior(async () => {
- await gate.promise;
- });
-
- h.editedScene({ title: "hide-1" });
- h.editedScene({ title: "hide-2" });
- expect(h.coord.getSnapshot().revision).toBe(2);
-
- const p1 = h.coord.handlePageHide();
- const p2 = h.coord.handlePageHide();
- expect(p1).toBe(p2);
-
- expect(h.requests).toHaveLength(1);
- expect(h.requests[0]?.revision).toBe(2);
- expect(h.requests[0]?.scene.title).toBe("hide-2");
-
- gate.resolve();
- expect(await p1).toBe(true);
- expect(h.coord.getSnapshot()).toMatchObject({
- status: "saved",
- confirmedRevision: 2,
- });
- });
- });
-
- describe("27 dispose", () => {
- it("should cancel debounce, stop notifying, and reject later edits", () => {
- const h = createHarness();
- const seen: string[] = [];
- h.coord.onStatusChange((status) => {
- seen.push(status);
- });
-
- expect(h.coord.edit(makeScene({ title: "before-dispose" }))).toBe(true);
- expect(seen).toEqual(["dirty"]);
- expect(h.scheduler.size()).toBe(1);
-
- h.coord.dispose();
- expect(h.scheduler.size()).toBe(0);
-
- expect(h.coord.edit(makeScene({ title: "after-dispose" }))).toBe(false);
- expect(seen).toEqual(["dirty"]);
- });
- });
-
- describe("28 dispose in-flight", () => {
- it("should not mark saved or confirmed when disposed mid-flight", async () => {
- const h = createHarness();
- const gate = deferred();
- h.setFlushBehavior(async () => {
- await gate.promise;
- });
-
- h.editedScene({ title: "in-flight" });
- const pending = h.coord.flushLatest();
- expect(h.requests).toHaveLength(1);
- expect(h.requests[0]?.revision).toBe(1);
-
- h.coord.dispose();
- gate.resolve();
-
- expect(await pending).toBe(false);
- const snap = h.coord.getSnapshot();
- expect(snap.confirmedRevision).toBe(0);
- expect(snap.status).not.toBe("saved");
- });
- });
-
- describe("29 unsubscribe", () => {
- it("should only remove the unsubscribed listener", () => {
- const h = createHarness();
- const first: string[] = [];
- const second: string[] = [];
-
- const unsubscribeFirst = h.coord.onStatusChange((status) => {
- first.push(status);
- });
- h.coord.onStatusChange((status) => {
- second.push(status);
- });
-
- unsubscribeFirst();
- h.editedScene({ title: "notify-second" });
-
- expect(first).toEqual([]);
- expect(second).toEqual(["dirty"]);
- });
- });
-
- describe("30 restoreCandidate on get fail", () => {
- it("should return null when storage.getItem throws", () => {
- const h = createHarness();
- h.storage.throwOnGet = true;
- expect(h.coord.restoreCandidate()).toBeNull();
- });
- });
-
- describe("31 discardDraft", () => {
- it("should really delete the scoped draft so the same candidate never returns", () => {
- const h = createHarness();
- h.editedScene({ title: "discard-me" });
- expect(h.coord.restoreCandidate()?.scene.title).toBe("discard-me");
-
- expect(h.coord.discardDraft()).toBe(true);
-
- expect(h.readEnvelope()).toBeNull();
- expect(h.storage.keys()).toEqual([]);
- expect(h.coord.restoreCandidate()).toBeNull();
- expect(h.coord.getSnapshot().draftStored).toBe(false);
- });
-
- it("should report false and keep the draft when removal fails", () => {
- const h = createHarness();
- h.editedScene({ title: "keep-me" });
-
- h.storage.throwOnRemove = true;
- expect(h.coord.discardDraft()).toBe(false);
-
- h.storage.throwOnRemove = false;
- expect(h.readEnvelope()?.scene.title).toBe("keep-me");
- expect(h.coord.getSnapshot().draftStored).toBe(true);
- });
-
- it("should leave a later flush unaffected after discarding", async () => {
- const h = createHarness();
- h.editedScene({ title: "discard-then-save" });
- expect(h.coord.discardDraft()).toBe(true);
-
- expect(await h.coord.flushLatest()).toBe(true);
- expect(h.requests).toHaveLength(1);
- expect(h.requests[0]?.scene.title).toBe("discard-then-save");
- expect(h.coord.getSnapshot()).toMatchObject({
- status: "saved",
- confirmedRevision: 1,
- });
- });
- });
-
- describe("32 restore then save", () => {
- it("should adopt the restored revision and confirm it on the next flush", async () => {
- const storage = new MemoryStorage();
- const previousSession = createHarness({ storage });
- previousSession.editedScene({
- title: "recovered",
- updatedAt: "2026-02-02T00:00:00.000Z",
- });
- previousSession.dispose();
-
- const reopened = createHarness({ storage });
- const candidate = reopened.coord.restoreCandidate();
- expect(candidate?.scene.title).toBe("recovered");
- expect(candidate).not.toBeNull();
- if (!candidate) return;
-
- expect(reopened.coord.explicitRestore(candidate)).toBe(true);
- expect(reopened.coord.getSnapshot()).toMatchObject({
- status: "dirty",
- revision: candidate.revision,
- confirmedRevision: 0,
- draftStored: true,
- });
-
- expect(await reopened.coord.flushLatest()).toBe(true);
- expect(reopened.requests).toHaveLength(1);
- expect(reopened.requests[0]?.scene.title).toBe("recovered");
- expect(reopened.requests[0]?.revision).toBe(candidate.revision);
- expect(reopened.coord.getSnapshot()).toMatchObject({
- status: "saved",
- confirmedRevision: candidate.revision,
- });
- expect(reopened.readEnvelope()).toBeNull();
- });
- });
-
- describe("33 status subscription and retry", () => {
- it("should publish the full dirty/saving/error/saved sequence across a failed then retried save", async () => {
- const h = createHarness();
- const seen: string[] = [];
- h.coord.onStatusChange((status) => {
- seen.push(status);
- });
-
- let failNext = true;
- h.setFlushBehavior(async () => {
- if (failNext) throw new Error("remote rejected");
- });
-
- h.editedScene({ title: "retry-me" });
- expect(seen).toEqual(["dirty"]);
-
- expect(await h.coord.flushLatest()).toBe(false);
- expect(seen).toEqual(["dirty", "saving", "error"]);
- expect(h.coord.getSnapshot().draftStored).toBe(true);
-
- failNext = false;
- expect(await h.coord.retry()).toBe(true);
- expect(seen).toEqual(["dirty", "saving", "error", "saving", "saved"]);
- expect(h.requests).toHaveLength(2);
- expect(h.requests[1]?.scene.title).toBe("retry-me");
- expect(h.coord.getSnapshot()).toMatchObject({
- status: "saved",
- confirmedRevision: 1,
- draftStored: false,
- });
- expect(h.readEnvelope()).toBeNull();
- });
-
- it("should stop publishing to a listener that unsubscribed mid-sequence", async () => {
- const h = createHarness();
- const seen: string[] = [];
- const unsubscribe = h.coord.onStatusChange((status) => {
- seen.push(status);
- });
-
- h.editedScene({ title: "unsub" });
- expect(seen).toEqual(["dirty"]);
-
- unsubscribe();
- expect(await h.coord.flushLatest()).toBe(true);
- expect(seen).toEqual(["dirty"]);
- expect(h.coord.getSnapshot().status).toBe("saved");
- });
- });
-
- describe("34 close decisions", () => {
- it("should return close without any request when nothing is dirty", async () => {
- const h = createHarness();
- expect(await h.coord.prepareClose()).toBe("close");
- expect(h.requests).toEqual([]);
- });
-
- it("should reuse one close promise while a slow save is in flight", async () => {
- const h = createHarness();
- const gate = deferred();
- h.setFlushBehavior(async () => {
- await gate.promise;
- });
-
- h.editedScene({ title: "slow-close" });
- const first = h.coord.prepareClose();
- const second = h.coord.prepareClose();
- expect(first).toBe(second);
- expect(h.requests).toHaveLength(1);
-
- gate.resolve();
- expect(await first).toBe("close");
- expect(h.coord.getSnapshot().confirmedRevision).toBe(1);
- });
-
- it("should offer draft exit when the remote fails but the draft survives", async () => {
- const h = createHarness();
- h.editedScene({ title: "draft-exit" });
- h.setFlushBehavior(async () => {
- throw new Error("remote rejected");
- });
-
- expect(await h.coord.prepareClose()).toBe("offer-draft-exit");
- expect(h.readEnvelope()?.scene.title).toBe("draft-exit");
-
- // 放弃草稿后再次关闭仍要如实回报失败,不能因为草稿消失就假装可以安全退出。
- expect(h.coord.discardDraft()).toBe(true);
- expect(await h.coord.prepareClose()).toBe("stay");
- });
-
- it("should stay when neither the remote nor the local draft succeeded", async () => {
- const h = createHarness();
- h.storage.throwOnSet = true;
- h.setFlushBehavior(async () => {
- throw new Error("remote rejected");
- });
-
- h.editedScene({ title: "no-escape" });
- expect(await h.coord.prepareClose()).toBe("stay");
- expect(h.coord.getSnapshot().draftStored).toBe(false);
- });
-
- it("should stay after dispose instead of reporting a clean close", async () => {
- const h = createHarness();
- h.editedScene({ title: "disposed-close" });
- h.coord.dispose();
- expect(await h.coord.prepareClose()).toBe("stay");
- });
- });
-
- describe("35 snapshot visible inside the status callback", () => {
- it("should expose a fully confirmed, draft-free snapshot when saved fires", async () => {
- const h = createHarness();
- const observed: Array<{ status: string; revision: number; confirmedRevision: number; draftStored: boolean }> = [];
-
- h.coord.onStatusChange((status) => {
- // 订阅方只拿到 status 是不够的:必须能在回调内读到完整的新快照。
- const snap = h.coord.getSnapshot();
- observed.push({
- status,
- revision: snap.revision,
- confirmedRevision: snap.confirmedRevision,
- draftStored: snap.draftStored,
- });
- });
-
- h.editedScene({ title: "observe-me" });
- expect(await h.coord.flushLatest()).toBe(true);
-
- expect(observed.map((entry) => entry.status)).toEqual(["dirty", "saving", "saved"]);
-
- const saved = observed.at(-1);
- expect(saved?.confirmedRevision).toBe(1);
- expect(saved?.revision).toBe(1);
- expect(saved?.confirmedRevision).toBe(saved?.revision);
- expect(saved?.draftStored).toBe(false);
- });
-
- it("should expose the retained draft and unconfirmed revision when error fires", async () => {
- const h = createHarness();
- let seen: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null;
-
- h.setFlushBehavior(async () => {
- throw new Error("remote rejected");
- });
- h.coord.onStatusChange((status) => {
- if (status !== "error") return;
- const snap = h.coord.getSnapshot();
- seen = {
- confirmedRevision: snap.confirmedRevision,
- revision: snap.revision,
- draftStored: snap.draftStored,
- };
- });
-
- h.editedScene({ title: "error-observe" });
- expect(await h.coord.flushLatest()).toBe(false);
-
- expect(seen).not.toBeNull();
- expect(seen?.revision).toBe(1);
- expect(seen?.confirmedRevision).toBe(0);
- expect(seen?.draftStored).toBe(true);
- });
-
- it("should reach a saved snapshot through the debounced schedule, not just explicit flush", async () => {
- const h = createHarness();
- let savedSnapshot: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null;
-
- h.coord.onStatusChange((status) => {
- if (status !== "saved") return;
- const snap = h.coord.getSnapshot();
- savedSnapshot = {
- confirmedRevision: snap.confirmedRevision,
- revision: snap.revision,
- draftStored: snap.draftStored,
- };
- });
-
- h.editedScene({ title: "debounced" });
- expect(h.scheduler.size()).toBe(1);
-
- // 自动 debounce 保存完成后,订阅方看到的必须已经是确认态。
- await h.flushMicrotasks();
-
- expect(h.requests).toHaveLength(1);
- expect(savedSnapshot).not.toBeNull();
- expect(savedSnapshot?.confirmedRevision).toBe(1);
- expect(savedSnapshot?.revision).toBe(1);
- expect(savedSnapshot?.draftStored).toBe(false);
- expect(h.readEnvelope()).toBeNull();
- });
- });
-});
diff --git a/web/test/director-save-wiring.test.ts b/web/test/director-save-wiring.test.ts
deleted file mode 100644
index d7d67418f..000000000
--- a/web/test/director-save-wiring.test.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import type { PrevisScene } from "../src/types/previs";
-import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisSaveSnapshot } from "../src/lib/canvas/previs/previs-save";
-import {
- describePrevisSaveStatus,
- previsSaveProgress,
- idlePrevisSaveProgress,
- resolvePrevisCloseOutcome,
- resolvePrevisDraftStorage,
- shouldBlockPrevisUnload,
- shouldOfferPrevisDraftRecovery,
- unavailablePrevisDraftStorage,
-} from "../src/lib/canvas/previs/previs-save-wiring";
-
-const makeScene = (overrides: Partial = {}): PrevisScene => ({
- id: "scene-1",
- version: 1,
- title: "Test Scene",
- background: "#000",
- environmentIntensity: 0.5,
- gridVisible: true,
- objects: [],
- cameras: [],
- lights: [],
- shots: [],
- activeShotId: "shot-1",
- createdAt: "2025-01-01T00:00:00.000Z",
- updatedAt: "2025-01-01T00:00:00.000Z",
- ...overrides,
-});
-
-const makeSnapshot = (overrides: Partial = {}): PrevisSaveSnapshot => ({
- scene: makeScene(),
- revision: 0,
- confirmedRevision: 0,
- status: "saved",
- draftStored: false,
- ...overrides,
-});
-
-const makeEnvelope = (overrides: Partial = {}): PrevisDraftEnvelope => {
- const scene = overrides.scene ?? makeScene();
- return {
- schemaVersion: 1,
- sceneId: scene.id,
- baseUpdatedAt: "2025-01-01T00:00:00.000Z",
- revision: 1,
- savedAt: 1000,
- ...overrides,
- scene,
- };
-};
-
-describe("describePrevisSaveStatus", () => {
- it("should report a quiet idle state only when everything is confirmed", () => {
- const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 3, confirmedRevision: 3 }));
- expect(indicator).toEqual({ label: "已保存", tone: "idle", retryable: false, busy: false });
- });
-
- it("should stay retryable when the status is saved but a revision is unconfirmed", () => {
- const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 4, confirmedRevision: 3 }));
- expect(indicator.label).toBe("有未保存修改");
- expect(indicator.tone).toBe("pending");
- expect(indicator.retryable).toBe(true);
- });
-
- it("should mark saving as busy and not retryable", () => {
- const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saving", revision: 2, confirmedRevision: 1 }));
- expect(indicator).toEqual({ label: "正在保存…", tone: "pending", retryable: false, busy: true });
- });
-
- it("should distinguish an error with a safe draft from one without", () => {
- const withDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: true }));
- const withoutDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: false }));
-
- expect(withDraft.label).toBe("保存失败 · 本地草稿已保留");
- expect(withoutDraft.label).toBe("保存失败 · 本地草稿也未写入");
- expect(withDraft.tone).toBe("danger");
- expect(withoutDraft.tone).toBe("danger");
- expect(withDraft.retryable).toBe(true);
- expect(withoutDraft.retryable).toBe(true);
- });
-
- it("should surface dirty as pending and retryable", () => {
- const indicator = describePrevisSaveStatus(makeSnapshot({ status: "dirty", revision: 1, confirmedRevision: 0, draftStored: true }));
- expect(indicator.label).toBe("有未保存修改");
- expect(indicator.retryable).toBe(true);
- expect(indicator.busy).toBe(false);
- });
-});
-
-describe("shouldOfferPrevisDraftRecovery", () => {
- it("should offer recovery when the draft is newer than the authoritative scene", () => {
- const authoritativeScene = makeScene({ updatedAt: "2025-01-01T00:00:00.000Z" });
- const candidate = makeEnvelope({
- baseUpdatedAt: "2025-01-01T00:00:00.000Z",
- scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
- });
-
- expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(true);
- });
-
- it("should not offer recovery when the project already holds that content", () => {
- const authoritativeScene = makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" });
- const candidate = makeEnvelope({
- baseUpdatedAt: "2025-06-01T00:00:00.000Z",
- scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
- });
-
- expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
- });
-
- it("should not offer a stale draft whose base fell behind the authoritative scene", () => {
- const authoritativeScene = makeScene({ updatedAt: "2025-09-01T00:00:00.000Z" });
- const candidate = makeEnvelope({
- baseUpdatedAt: "2025-01-01T00:00:00.000Z",
- scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
- });
-
- expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
- });
-
- it("should reject a candidate belonging to another scene", () => {
- const authoritativeScene = makeScene({ id: "scene-1" });
- const candidate = makeEnvelope({
- sceneId: "scene-2",
- scene: makeScene({ id: "scene-2", updatedAt: "2025-06-01T00:00:00.000Z" }),
- });
-
- expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
- });
-
- it("should never prompt without both a candidate and an authoritative scene", () => {
- expect(shouldOfferPrevisDraftRecovery({ candidate: null, authoritativeScene: makeScene() })).toBe(false);
- expect(shouldOfferPrevisDraftRecovery({ candidate: makeEnvelope(), authoritativeScene: null })).toBe(false);
- });
-});
-
-describe("resolvePrevisCloseOutcome", () => {
- it("should close outright on a clean decision", () => {
- expect(resolvePrevisCloseOutcome("close")).toEqual({ kind: "close" });
- });
-
- it("should require confirmation and name the safe draft on offer-draft-exit", () => {
- const outcome = resolvePrevisCloseOutcome("offer-draft-exit");
- expect(outcome.kind).toBe("confirm-draft-exit");
- if (outcome.kind !== "confirm-draft-exit") return;
- expect(outcome.message).toContain("远端保存失败");
- expect(outcome.message).toContain("本地草稿");
- });
-
- it("should block the exit and explain why on stay", () => {
- const outcome = resolvePrevisCloseOutcome("stay");
- expect(outcome.kind).toBe("blocked");
- if (outcome.kind !== "blocked") return;
- expect(outcome.message).toContain("重试");
- });
-});
-
-describe("shouldBlockPrevisUnload", () => {
- it("should block while any revision is unconfirmed", () => {
- expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 2, confirmedRevision: 1 }))).toBe(true);
- });
-
- it("should block an error state that has no safe draft", () => {
- expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: false }))).toBe(true);
- });
-
- it("should let an error state with a safe draft through", () => {
- expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: true }))).toBe(false);
- });
-
- it("should not block a fully confirmed session", () => {
- expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 5, confirmedRevision: 5 }))).toBe(false);
- });
-});
-
-describe("previsSaveProgress", () => {
- it("should project every progress field and drop the scene", () => {
- const progress = previsSaveProgress(makeSnapshot({ status: "saving", revision: 4, confirmedRevision: 2, draftStored: true }));
-
- expect(progress).toEqual({
- status: "saving",
- revision: 4,
- confirmedRevision: 2,
- draftStored: true,
- });
- expect("scene" in progress).toBe(false);
- });
-
- it("should start idle with nothing unconfirmed and no draft", () => {
- expect(idlePrevisSaveProgress).toEqual({
- status: "saved",
- revision: 0,
- confirmedRevision: 0,
- draftStored: false,
- });
- expect(describePrevisSaveStatus(idlePrevisSaveProgress).retryable).toBe(false);
- expect(shouldBlockPrevisUnload(idlePrevisSaveProgress)).toBe(false);
- });
-});
-
-describe("resolvePrevisDraftStorage", () => {
- it("should delegate to the provided storage when it is usable", () => {
- const values = new Map();
- const storage = resolvePrevisDraftStorage(
- () =>
- ({
- getItem: (key: string) => values.get(key) ?? null,
- setItem: (key: string, value: string) => {
- values.set(key, value);
- },
- removeItem: (key: string) => {
- values.delete(key);
- },
- }) as unknown as Storage,
- );
-
- storage.setItem("draft", "payload");
- expect(storage.getItem("draft")).toBe("payload");
- storage.removeItem("draft");
- expect(storage.getItem("draft")).toBeNull();
- });
-
- it("should fall back to the unavailable implementation when the getter throws", () => {
- const storage = resolvePrevisDraftStorage(() => {
- throw new Error("localStorage blocked");
- });
- expect(storage).toBe(unavailablePrevisDraftStorage);
- });
-
- it("should fall back to the unavailable implementation when there is no storage", () => {
- expect(resolvePrevisDraftStorage(() => null)).toBe(unavailablePrevisDraftStorage);
- expect(resolvePrevisDraftStorage(() => undefined)).toBe(unavailablePrevisDraftStorage);
- });
-});
-
-describe("unavailablePrevisDraftStorage", () => {
- it("should read empty and refuse to write, so no draft is ever claimed as safe", () => {
- expect(unavailablePrevisDraftStorage.getItem("draft")).toBeNull();
- expect(() => unavailablePrevisDraftStorage.setItem("draft", "payload")).toThrow();
- expect(() => unavailablePrevisDraftStorage.removeItem("draft")).toThrow();
- });
-
- it("should drive the coordinator to draftStored false and a stay decision", async () => {
- const coordinator = createPrevisSaveCoordinator({
- initialScene: makeScene(),
- scope: "user-a",
- storage: unavailablePrevisDraftStorage,
- flush: async () => {
- throw new Error("remote rejected");
- },
- schedule: () => 0,
- cancelSchedule: () => undefined,
- });
-
- expect(coordinator.edit(makeScene({ title: "no-draft" }))).toBe(true);
- expect(coordinator.getSnapshot().draftStored).toBe(false);
- expect(coordinator.restoreCandidate()).toBeNull();
-
- // 没有安全草稿时必须留在工作台,不能谎称「下次可恢复」。
- expect(await coordinator.prepareClose()).toBe("stay");
- });
-});
diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts
index 03c8eb717..468502038 100644
--- a/web/test/model-picker-groups.test.ts
+++ b/web/test/model-picker-groups.test.ts
@@ -35,7 +35,7 @@ test("same model display name groups all channels and preserves their prices", (
expect(groups).toHaveLength(1);
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
- ["Seedance 2.0", "product", [["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
+ ["Seedance 2.0", "product", [["系统模型", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
]);
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
});
diff --git a/web/test/user-data-sync-media.test.ts b/web/test/user-data-sync-media.test.ts
new file mode 100644
index 000000000..f877ff365
--- /dev/null
+++ b/web/test/user-data-sync-media.test.ts
@@ -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("从资源恢复的正文");
+ });
+});
diff --git a/web/test/welcome-contributors.test.ts b/web/test/welcome-contributors.test.ts
index 5ec97eee2..d5550c614 100644
--- a/web/test/welcome-contributors.test.ts
+++ b/web/test/welcome-contributors.test.ts
@@ -21,6 +21,7 @@ test("welcome contributors parse the README HTML table", () => {
expect(people[0]?.signature).toContain("没有任何黑魔法");
expect(people.some((person) => person.name === "爱笑的毛毛虫")).toBe(true);
expect(people.some((person) => person.name === "morrow" && person.email === "3201190041@qq.com" && person.signature === "最难不过坚持")).toBe(true);
+ expect(people.some((person) => person.name === "Wyatt_K" && person.avatar === "assets/user-Wyatt_K.png" && person.signature === "千里之行,始于足下。")).toBe(true);
expect(people.some((person) => person.name === "亿媒网老宋" && person.avatar === "assets/user-song.jpg" && person.email === "61731514@qq.com" && person.signature === "全国10W+媒体、自媒体,助力企业品牌传播")).toBe(true);
expect(people.filter((person) => person.isFounder)).toHaveLength(1);
});