修复资源依赖图布局初始化与拖动性能
Project CI / Repository checks (pull_request) Successful in 1m0s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 3m29s
Project CI / Native shell tests (pull_request) Failing after 5m34s

将任务依赖深度迁移到 Rust SCC 压缩图并返回稳定结果

为 dependency 模式增加图加载屏障并重派生自动布局

通过 DOM 热路径和局部 SVG 缓存避免拖动全量 React 重渲染

补充 4096 资源、异步初始化及布局保持回归测试

同步工作台 PRD、技术方案与共享项目记忆
This commit is contained in:
2026-08-03 11:16:02 +08:00
parent 37f4a63112
commit 05c608214e
14 changed files with 1087 additions and 318 deletions
@@ -50,6 +50,7 @@ pub(crate) struct ProjectResourceConnectionIndex {
pub(crate) struct ProjectResourceProducerAssignment {
pub resource_id: String,
pub task_id: String,
pub dependency_depth: u32,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -77,6 +78,7 @@ struct DirectedEdge {
struct CycleAnalysis {
cyclic_node_ids: BTreeSet<String>,
cyclic_edge_ids: BTreeSet<String>,
component_by_node: BTreeMap<String, usize>,
}
#[derive(Debug, Default)]
@@ -185,9 +187,68 @@ fn analyze_directed_cycles<'a>(
result.cyclic_edge_ids.insert(edge.id.clone());
}
}
result.component_by_node = component_by_node;
result
}
fn dependency_depth_by_node(
analysis: &CycleAnalysis,
edges: &[DirectedEdge],
) -> BTreeMap<String, u32> {
let component_count = analysis
.component_by_node
.values()
.copied()
.max()
.map_or(0, |max_component| max_component + 1);
let mut outgoing = vec![BTreeSet::<usize>::new(); component_count];
let mut indegree = vec![0usize; component_count];
for edge in edges {
let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else {
continue;
};
let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else {
continue;
};
if source_component != target_component
&& outgoing[source_component].insert(target_component)
{
indegree[target_component] += 1;
}
}
let mut ready = indegree
.iter()
.enumerate()
.filter_map(|(component, degree)| (*degree == 0).then_some(component))
.collect::<BTreeSet<_>>();
let mut depth_by_component = vec![0u32; component_count];
while let Some(component) = ready.pop_first() {
for &target in &outgoing[component] {
depth_by_component[target] =
depth_by_component[target].max(depth_by_component[component].saturating_add(1));
indegree[target] -= 1;
if indegree[target] == 0 {
ready.insert(target);
}
}
}
analysis
.component_by_node
.iter()
.map(|(node_id, component)| {
(
node_id.clone(),
depth_by_component
.get(*component)
.copied()
.unwrap_or_default(),
)
})
.collect()
}
fn audit_asset_producers(
records: &[serde_json::Value],
task_ids: &BTreeSet<String>,
@@ -403,6 +464,7 @@ pub(crate) fn build_project_resource_graph(
})
.collect::<Vec<_>>();
let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges);
let task_dependency_depths = dependency_depth_by_node(&task_cycles, &task_dependency_edges);
let task_flows = task_dependency_edges
.iter()
.filter_map(|edge| {
@@ -476,6 +538,10 @@ pub(crate) fn build_project_resource_graph(
.into_iter()
.map(|(resource_id, task_id)| ProjectResourceProducerAssignment {
resource_id,
dependency_depth: task_dependency_depths
.get(&task_id)
.copied()
.unwrap_or_default(),
task_id,
})
.collect(),
@@ -610,6 +676,14 @@ mod tests {
assert_eq!(graph.task_flows.len(), 1);
assert_eq!(graph.task_flows[0].source_task_id, "art-director");
assert_eq!(graph.task_flows[0].target_task_id, "design-foundation");
assert_eq!(
graph
.producer_assignments
.iter()
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,))
.collect::<BTreeMap<_, _>>(),
BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]),
);
assert!(graph
.producer_assignments
.iter()
@@ -734,5 +808,46 @@ mod tests {
.connection_index
.iter()
.all(|index| index.task_flow_ids.len() <= 2));
assert_eq!(
graph
.producer_assignments
.iter()
.find(|assignment| assignment.resource_id == "resource:4095")
.map(|assignment| assignment.dependency_depth),
Some(4095),
);
}
#[test]
fn dependency_depth_collapses_cycles_before_following_downstream_tasks() {
let graph = build_project_resource_graph(
&manifest(
vec![
task("source", &[]),
task("cycle-a", &["source", "cycle-b"]),
task("cycle-b", &["cycle-a"]),
task("target", &["cycle-b"]),
],
Vec::new(),
),
vec![
resource("source-resource", None, Some("source")),
resource("cycle-a-resource", None, Some("cycle-a")),
resource("cycle-b-resource", None, Some("cycle-b")),
resource("target-resource", None, Some("target")),
],
&[],
false,
);
let depths = graph
.producer_assignments
.iter()
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth))
.collect::<BTreeMap<_, _>>();
assert_eq!(depths["source-resource"], 0);
assert_eq!(depths["cycle-a-resource"], 1);
assert_eq!(depths["cycle-b-resource"], 1);
assert_eq!(depths["target-resource"], 2);
}
}
@@ -1,4 +1,13 @@
import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
ProjectResourceCanvasPosition,
@@ -34,11 +43,21 @@ type RectLookup = {
export type ResourceDependencyOverlayProps = {
graph: ProjectResourceGraph;
positions: readonly ProjectResourceCanvasPosition[];
dragPreview: (Point & { resourceId: string }) | null;
visibleResourceIds: ReadonlySet<string>;
selectedResourceId: string | null;
};
export type ResourceDependencyOverlayHandle = {
updateDragPreview: (preview: Point & { resourceId: string }) => void;
clearDragPreview: () => void;
};
type TaskFlowPathRefs = {
sourceBranches: Map<string, SVGPathElement>;
targetBranches: Map<string, SVGPathElement>;
trunk: SVGPathElement | null;
};
const SECTION_SELECTOR = '[data-resource-section-plane]';
const TASK_FLOW_HUB_GAP = 20;
const CONNECTION_MAX_HANDLE = 180;
@@ -200,18 +219,25 @@ function taskFlowGeometry(
return { sourceAnchors, targetAnchors, sourceHub, targetHub };
}
export function ResourceDependencyOverlay({
graph,
positions,
dragPreview,
visibleResourceIds,
selectedResourceId,
}: ResourceDependencyOverlayProps) {
export const ResourceDependencyOverlay = forwardRef<
ResourceDependencyOverlayHandle,
ResourceDependencyOverlayProps
>(function ResourceDependencyOverlay(
{ graph, positions, visibleResourceIds, selectedResourceId },
ref,
) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
const overlayRef = useRef<SVGSVGElement>(null);
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
const taskFlowGroupRefs = useRef(new Map<string, SVGGElement>());
const activeDragResourceIdRef = useRef<string | null>(null);
const taskFlowPathRefs = useRef(new Map<string, TaskFlowPathRefs>());
const activeDragPreviewRef = useRef<
(Point & { resourceId: string }) | null
>(null);
const graphRef = useRef(graph);
const positionByResourceIdRef = useRef(
new Map(positions.map((position) => [position.resourceId, position])),
);
const rectByResourceIdRef = useRef<Map<string, Rect>>(new Map());
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
useLayoutEffect(() => {
@@ -290,6 +316,11 @@ export function ResourceDependencyOverlay({
}
return result;
}, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]);
graphRef.current = graph;
positionByResourceIdRef.current = new Map(
positions.map((position) => [position.resourceId, position]),
);
rectByResourceIdRef.current = rectByResourceId;
const neighbors = useMemo(
() => projectResourceGraphNeighbors(graph, selectedResourceId),
@@ -312,13 +343,6 @@ export function ResourceDependencyOverlay({
}${flow.cyclic ? ' is-cyclic' : ''}`;
return (
<g
ref={(node) => {
if (node) {
taskFlowGroupRefs.current.set(flow.id, node);
} else {
taskFlowGroupRefs.current.delete(flow.id);
}
}}
key={flow.id}
className={className}
data-edge-kind="task-flow"
@@ -332,6 +356,22 @@ export function ResourceDependencyOverlay({
}`}</title>
{geometry.sourceAnchors.map(({ resourceId, point }) => (
<path
ref={(node) => {
let refs = taskFlowPathRefs.current.get(flow.id);
if (!refs) {
refs = {
sourceBranches: new Map(),
targetBranches: new Map(),
trunk: null,
};
taskFlowPathRefs.current.set(flow.id, refs);
}
if (node) {
refs.sourceBranches.set(resourceId, node);
} else {
refs.sourceBranches.delete(resourceId);
}
}}
key={`source:${resourceId}`}
className="game-resource-dependency-branch"
data-branch-side="source"
@@ -340,11 +380,39 @@ export function ResourceDependencyOverlay({
/>
))}
<path
ref={(node) => {
let refs = taskFlowPathRefs.current.get(flow.id);
if (!refs) {
refs = {
sourceBranches: new Map(),
targetBranches: new Map(),
trunk: null,
};
taskFlowPathRefs.current.set(flow.id, refs);
}
refs.trunk = node;
}}
className="game-resource-dependency-trunk"
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
/>
{geometry.targetAnchors.map(({ resourceId, point }) => (
<path
ref={(node) => {
let refs = taskFlowPathRefs.current.get(flow.id);
if (!refs) {
refs = {
sourceBranches: new Map(),
targetBranches: new Map(),
trunk: null,
};
taskFlowPathRefs.current.set(flow.id, refs);
}
if (node) {
refs.targetBranches.set(resourceId, node);
} else {
refs.targetBranches.delete(resourceId);
}
}}
key={`target:${resourceId}`}
className="game-resource-dependency-branch"
data-branch-side="target"
@@ -410,49 +478,49 @@ export function ResourceDependencyOverlay({
);
useLayoutEffect(() => {
const affectedResourceIds = new Set<string>();
if (activeDragResourceIdRef.current) {
affectedResourceIds.add(activeDragResourceIdRef.current);
}
if (dragPreview) {
affectedResourceIds.add(dragPreview.resourceId);
}
activeDragResourceIdRef.current = dragPreview?.resourceId ?? null;
if (affectedResourceIds.size === 0) {
return;
const activeFlowIds = new Set(graph.taskFlows.map((flow) => flow.id));
for (const flowId of taskFlowPathRefs.current.keys()) {
if (!activeFlowIds.has(flowId)) {
taskFlowPathRefs.current.delete(flowId);
}
}
}, [graph.taskFlows]);
const updateAffectedGeometry = useCallback(
(
affectedResourceIds: ReadonlySet<string>,
dragPreview: (Point & { resourceId: string }) | null,
) => {
const currentGraph = graphRef.current;
const currentRects = rectByResourceIdRef.current;
const dragBasePosition = dragPreview
? positions.find(
(position) => position.resourceId === dragPreview.resourceId,
)
? positionByResourceIdRef.current.get(dragPreview.resourceId)
: undefined;
const rectLookup = {
get(resourceId: string) {
const rect = rectByResourceId.get(resourceId);
const rect = currentRects.get(resourceId);
if (!rect) {
return undefined;
}
return dragPreview?.resourceId === resourceId
? {
...rect,
x:
rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
y:
rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
}
: rect;
},
};
const affectedEdgeIds = new Set<string>();
for (const resourceId of affectedResourceIds) {
const index = graph.connectionIndex.get(resourceId);
const index = currentGraph.connectionIndex.get(resourceId);
index?.referenceEdgeIds.forEach((edgeId) =>
affectedEdgeIds.add(edgeId),
);
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
}
for (const edgeId of affectedEdgeIds) {
const referenceEdge = graph.referenceEdgeById.get(edgeId);
const referenceEdge = currentGraph.referenceEdgeById.get(edgeId);
if (referenceEdge) {
const geometry = referenceGeometry(referenceEdge, rectLookup);
const path = referencePathRefs.current.get(edgeId);
@@ -461,45 +529,29 @@ export function ResourceDependencyOverlay({
}
continue;
}
const flow = graph.taskFlowById.get(edgeId);
const group = taskFlowGroupRefs.current.get(edgeId);
if (!flow || !group) {
const flow = currentGraph.taskFlowById.get(edgeId);
const paths = taskFlowPathRefs.current.get(edgeId);
if (!flow || !paths) {
continue;
}
const geometry = taskFlowGeometry(flow, rectLookup);
if (!geometry) {
continue;
}
const sourcePathByResourceId = new Map<string, SVGPathElement>();
const targetPathByResourceId = new Map<string, SVGPathElement>();
group
.querySelectorAll<SVGPathElement>('.game-resource-dependency-branch')
.forEach((path) => {
const resourceId = path.dataset.resourceId;
if (!resourceId) {
return;
}
(path.dataset.branchSide === 'source'
? sourcePathByResourceId
: targetPathByResourceId
).set(resourceId, path);
});
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
sourcePathByResourceId
paths.sourceBranches
.get(resourceId)
?.setAttribute(
'd',
taskFlowBranchPath(point, geometry.sourceHub),
);
});
group
.querySelector<SVGPathElement>('.game-resource-dependency-trunk')
?.setAttribute(
'd',
connectionPath(geometry.sourceHub, geometry.targetHub),
);
paths.trunk?.setAttribute(
'd',
connectionPath(geometry.sourceHub, geometry.targetHub),
);
geometry.targetAnchors.forEach(({ resourceId, point }) => {
targetPathByResourceId
paths.targetBranches
.get(resourceId)
?.setAttribute(
'd',
@@ -507,7 +559,32 @@ export function ResourceDependencyOverlay({
);
});
}
}, [dragPreview, graph, positions, rectByResourceId]);
},
[],
);
useImperativeHandle(
ref,
() => ({
updateDragPreview(preview) {
const affectedResourceIds = new Set<string>();
if (activeDragPreviewRef.current) {
affectedResourceIds.add(activeDragPreviewRef.current.resourceId);
}
affectedResourceIds.add(preview.resourceId);
activeDragPreviewRef.current = preview;
updateAffectedGeometry(affectedResourceIds, preview);
},
clearDragPreview() {
const active = activeDragPreviewRef.current;
activeDragPreviewRef.current = null;
if (active) {
updateAffectedGeometry(new Set([active.resourceId]), null);
}
},
}),
[updateAffectedGeometry],
);
return (
<svg
@@ -550,4 +627,4 @@ export function ResourceDependencyOverlay({
{renderReferenceEdges}
</svg>
);
}
});
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,7 @@ export type ProjectResourceConnectionIndexDto = {
export type ProjectResourceProducerAssignment = {
resourceId: string;
taskId: string;
dependencyDepth: number;
};
export type ProjectResourceGraphReadModel = {
@@ -62,6 +63,7 @@ export type ProjectResourceGraph = {
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
producerTaskIdByResourceId: ReadonlyMap<string, string>;
dependencyDepthByResourceId: ReadonlyMap<string, number>;
unresolvedReferenceResourceIds: string[];
cyclicResourceIds: ReadonlySet<string>;
cyclicTaskIds: ReadonlySet<string>;
@@ -76,6 +78,7 @@ export type ProjectResourceGraphNeighbors = {
const emptyStringSet: ReadonlySet<string> = new Set<string>();
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
const emptyNumberMap: ReadonlyMap<string, number> = new Map<string, number>();
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
new Map<string, ProjectResourceConnectionIndex>();
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
@@ -102,6 +105,7 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
taskFlowById: emptyTaskFlowMap,
connectionIndex: emptyConnectionMap,
producerTaskIdByResourceId: emptyStringMap,
dependencyDepthByResourceId: emptyNumberMap,
unresolvedReferenceResourceIds: [],
cyclicResourceIds: emptyStringSet,
cyclicTaskIds: emptyStringSet,
@@ -174,13 +178,23 @@ export function normalizeProjectResourceGraph(
),
});
}
const producerTaskIdByResourceId = new Map(
readModel.producerAssignments.flatMap((assignment) =>
resourceIds.has(assignment.resourceId) && assignment.taskId
? [[assignment.resourceId, assignment.taskId] as const]
: [],
),
);
const producerTaskIdByResourceId = new Map<string, string>();
const dependencyDepthByResourceId = new Map<string, number>();
for (const assignment of readModel.producerAssignments) {
if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) {
continue;
}
producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId);
if (
Number.isSafeInteger(assignment.dependencyDepth) &&
assignment.dependencyDepth >= 0
) {
dependencyDepthByResourceId.set(
assignment.resourceId,
assignment.dependencyDepth,
);
}
}
return {
resourceIds,
@@ -190,6 +204,7 @@ export function normalizeProjectResourceGraph(
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
connectionIndex,
producerTaskIdByResourceId,
dependencyDepthByResourceId,
unresolvedReferenceResourceIds: uniqueSorted(
readModel.unresolvedReferenceResourceIds,
),
@@ -81,16 +81,58 @@ function layoutMatchesScope(
return layout.projectId === scope.projectId && layout.mode === scope.mode;
}
function positionsEqual(
left: ProjectResourceCanvasLayout['positions'],
right: ProjectResourceCanvasLayout['positions'],
) {
return (
left.length === right.length &&
left.every((position, index) => {
const other = right[index];
return (
other?.resourceId === position.resourceId &&
other.section === position.section &&
other.x === position.x &&
other.y === position.y &&
other.manuallyPlaced === position.manuallyPlaced
);
})
);
}
function reconcileLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
rederiveAutomaticPositions: boolean,
) {
if (!rederiveAutomaticPositions) {
return reconcileResourceCanvasLayout(source, resources);
}
const manualSource = {
...source,
positions: source.positions.filter((position) => position.manuallyPlaced),
};
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
return {
layout: reconciled.layout,
changed: !positionsEqual(source.positions, reconciled.layout.positions),
};
}
export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources,
initializationReady = true,
rederiveAutomaticPositions = false,
}: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
initializationReady?: boolean;
rederiveAutomaticPositions?: boolean;
}) {
const scopeKey = createScopeKey(projectPath, projectId, mode);
const resourceSignature = useMemo(
@@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({
[resources],
);
const fallback = useMemo(
() =>
reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
resources,
).layout,
[mode, projectId, resources],
);
() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
: empty;
}, [
initializationReady,
mode,
projectId,
rederiveAutomaticPositions,
resources,
]);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
@@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({
if (scope.epoch !== scopeEpoch) {
return;
}
let next = reconcileResourceCanvasLayout(
let next = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
).layout;
for (const intent of writeQueueRef.current) {
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
@@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({
}
applyLayout(next);
},
[applyLayout],
[applyLayout, rederiveAutomaticPositions],
);
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
@@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({
return;
}
const reconciled = reconcileResourceCanvasLayout(
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
);
if (intent.kind === 'resources' && !reconciled.changed) {
removeWriteIntent(intent);
@@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({
setNotice('布局已保存');
}
if (
reconcileResourceCanvasLayout(result.layout, resourcesRef.current)
reconcileLayout(
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
)
.changed
) {
enqueueResourceSyncRef.current(currentScope.epoch);
@@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({
queued.scopeEpoch !== currentScope.epoch ||
queued.kind !== 'manual',
);
const needsResourceSync = reconcileResourceCanvasLayout(
const needsResourceSync = reconcileLayout(
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
).changed;
const nextRetry =
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
@@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({
writeQueueRef.current = [];
activeWriteIntentRef.current = null;
redragRequiredScopeEpochRef.current = null;
const initialFallback = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
if (!initializationReady) {
persistedLayoutRef.current = emptyLayout;
applyLayout(emptyLayout);
setNotice('');
setSaving(false);
return undefined;
}
const initialFallback = reconcileLayout(
emptyLayout,
resourcesRef.current,
rederiveAutomaticPositions,
).layout;
persistedLayoutRef.current = initialFallback;
applyLayout(initialFallback);
@@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current = loaded;
initializedScopeEpochRef.current = epoch;
if (
reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed
reconcileLayout(
loaded,
resourcesRef.current,
rederiveAutomaticPositions,
).changed
) {
enqueueResourceSyncRef.current(epoch);
}
@@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({
};
}, [
applyLayout,
initializationReady,
mode,
projectId,
projectPath,
rebuildOptimisticLayout,
rederiveAutomaticPositions,
scopeKey,
]);
@@ -502,25 +571,34 @@ export function useProjectResourceCanvasLayout({
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const reconciledCurrent = reconcileResourceCanvasLayout(
const reconciledCurrent = reconcileLayout(
layoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
);
applyLayout(reconciledCurrent.layout);
if (
window.__TAURI__?.core?.invoke &&
reconcileResourceCanvasLayout(
reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
).changed
) {
enqueueResourceSyncRef.current(scope.epoch);
}
}, [applyLayout, resourceSignature, scopeKey]);
}, [
applyLayout,
initializationReady,
rederiveAutomaticPositions,
resourceSignature,
scopeKey,
]);
useEffect(() => {
if (!notice) {
@@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({
y: number,
) => {
const scope = scopeRef.current;
if (scope.key !== scopeKey) {
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queuedIntent = writeQueueRef.current.find(
@@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({
setSaving(true);
pumpWritesRef.current();
},
[applyLayout, scopeKey],
[applyLayout, initializationReady, scopeKey],
);
const scopeMatches =
initializationReady &&
scopeRef.current.key === scopeKey &&
layout.projectId === projectId &&
layout.mode === mode;
@@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
@@ -9,7 +9,10 @@ import {
type ProjectResourceGraph,
type ProjectResourceGraphReadModel,
} from '../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
import {
ResourceDependencyOverlay,
type ResourceDependencyOverlayHandle,
} from '../src/view/project-development/ResourceDependencyOverlay';
function position(
resourceId: string,
@@ -103,22 +106,22 @@ function OverlayHarness({
positions,
visibleResourceIds,
selectedResourceId = null,
dragPreview = null,
overlayRef,
}: {
graph: ProjectResourceGraph;
positions: ProjectResourceCanvasPosition[];
visibleResourceIds: ReadonlySet<string>;
selectedResourceId?: string | null;
dragPreview?: { resourceId: string; x: number; y: number } | null;
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>;
}) {
return React.createElement(
'div',
null,
React.createElement('div', { 'data-resource-section-plane': 'art' }),
React.createElement(ResourceDependencyOverlay, {
ref: overlayRef,
graph,
positions,
dragPreview,
visibleResourceIds,
selectedResourceId,
}),
@@ -130,14 +133,14 @@ function overlayView(
positions: ProjectResourceCanvasPosition[],
visibleResourceIds: ReadonlySet<string>,
selectedResourceId: string | null = null,
dragPreview: { resourceId: string; x: number; y: number } | null = null,
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>,
) {
return React.createElement(OverlayHarness, {
graph,
positions,
visibleResourceIds,
selectedResourceId,
dragPreview,
overlayRef,
});
}
@@ -181,8 +184,15 @@ describe('ResourceDependencyOverlay', () => {
position('source:one', 0, 0),
position('target:one', 240, 0),
];
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
const view = render(
overlayView(graph, positions, new Set(['source:one', 'target:one'])),
overlayView(
graph,
positions,
new Set(['source:one', 'target:one']),
null,
overlayRef,
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const firstPath = await waitFor(() => {
@@ -193,14 +203,12 @@ describe('ResourceDependencyOverlay', () => {
return path?.getAttribute('d');
});
view.rerender(
overlayView(
graph,
positions,
new Set(['source:one', 'target:one']),
null,
{ resourceId: 'source:one', x: 80, y: 40 },
),
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'source:one',
x: 80,
y: 40,
}),
);
await waitFor(() =>
expect(
@@ -242,7 +250,8 @@ describe('ResourceDependencyOverlay', () => {
position('target:one', 240, 0),
];
const visible = new Set(['source:one', 'target:one']);
const view = render(overlayView(graph, positions, visible));
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
render(overlayView(graph, positions, visible, null, overlayRef));
const overlay = await screen.findByTestId('resource-dependency-overlay');
const selector = '[data-edge-kind="asset-reference"]';
await waitFor(() =>
@@ -251,8 +260,8 @@ describe('ResourceDependencyOverlay', () => {
),
);
view.rerender(
overlayView(graph, positions, visible, null, {
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'source:one',
x: 80,
y: 40,
@@ -375,7 +384,8 @@ describe('ResourceDependencyOverlay', () => {
position('resource:2049', 440, 0),
];
const visible = new Set(positions.map(({ resourceId }) => resourceId));
const view = render(overlayView(graph, positions, visible));
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
render(overlayView(graph, positions, visible, null, overlayRef));
const overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() =>
expect(
@@ -387,8 +397,8 @@ describe('ResourceDependencyOverlay', () => {
'setAttribute',
);
try {
view.rerender(
overlayView(graph, positions, visible, null, {
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'resource:2048',
x: 260,
y: 32,
@@ -419,23 +429,24 @@ describe('ResourceDependencyOverlay', () => {
const positions = Array.from(graph.resourceIds).map(
(resourceId, index) => position(resourceId, index * 200, 0),
);
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
const view = render(
overlayView(
graph,
positions,
new Set(graph.resourceIds),
null,
overlayRef,
),
);
expect(observe).toHaveBeenCalledTimes(2);
view.rerender(
overlayView(
graph,
positions,
new Set(graph.resourceIds),
null,
{ resourceId: 'source:one', x: 32, y: 24 },
),
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'source:one',
x: 32,
y: 24,
}),
);
expect(observe).toHaveBeenCalledTimes(2);
view.unmount();
@@ -801,7 +801,9 @@ export function registerHomeProjectCreationTests() {
expect(screen.getByLabelText('项目总控消息').textContent).toContain(
'第一行\n第二行\n第三行',
);
expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull();
expect(
await screen.findByText('assets/uploads/reference.png'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath: '/tmp/home-created-game',
projectId: 'local-project-draft',
@@ -56,6 +56,40 @@ import {
within,
} from './harness';
function resourceGraphForInputs(args?: Record<string, unknown>) {
const resources =
(args?.resources as
| Array<{ resourceId: string; producerTaskId: string | null }>
| undefined) ?? [];
return {
resourceIds: resources.map(({ resourceId }) => resourceId),
referenceEdges: [],
taskFlows: [],
connectionIndex: resources.map(({ resourceId }) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: resources.flatMap((resource) =>
resource.producerTaskId
? [
{
resourceId: resource.resourceId,
taskId: resource.producerTaskId,
dependencyDepth: 0,
},
]
: [],
),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function gameChatRuntimeEvent({
agentId = 'project-supervisor',
taskId = agentId,
@@ -869,10 +903,12 @@ export function registerProjectWorkbenchFoundationTests() {
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
dependencyDepth: 1,
},
],
unresolvedReferenceResourceIds: [],
@@ -1025,6 +1061,204 @@ export function registerProjectWorkbenchFoundationTests() {
expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull();
});
it('waits for the scoped resource graph before initializing dependency layout', async () => {
const projectId = 'workbench-delayed-resource-graph';
const projectPath = '/tmp/workbench-delayed-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'delayed-graph-run',
label: '玩法策划 Agent',
title: '延迟依赖图回执',
content: '图就绪后再初始化布局',
updatedAt: 1,
},
];
let resolveGraph: (() => void) | null = null;
let layoutReads = 0;
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return await new Promise((resolve) => {
resolveGraph = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
layoutReads += 1;
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '延迟依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() => expect(resolveGraph).not.toBeNull());
expect(layoutReads).toBe(0);
expect(screen.queryByText('延迟依赖图回执')).toBeNull();
await act(async () => {
resolveGraph?.();
await Promise.resolve();
});
expect(await screen.findByText('延迟依赖图回执')).not.toBeNull();
expect(layoutReads).toBe(1);
});
it(
'keeps 4096 real resource cards out of React commits during preview frames',
async () => {
const projectId = 'workbench-resource-drag-performance';
const projectPath = '/tmp/workbench-resource-drag-performance';
const manifest = createGameCreationAppManifest(
projectId,
'资源拖动性能测试',
);
const agentResults = Array.from({ length: 4096 }, (_, index) => ({
agentId: 'design-foundation',
runId: `performance-run-${index}`,
label: `性能 Agent ${index}`,
title: `性能资源 ${index}`,
content: `性能正文 ${index}`,
updatedAt: index,
}));
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const commits: string[] = [];
render(
React.createElement(
React.Profiler,
{
id: 'resource-workbench',
onRender: () => commits.push('commit'),
},
React.createElement(ProjectDevelopmentView, {
projectName: '资源拖动性能测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
),
);
const target = (await screen.findByText('性能资源 2048')).closest(
'button',
);
expect(target).not.toBeNull();
expect(document.querySelectorAll('.game-resource-card')).toHaveLength(
4096,
);
commits.length = 0;
let previewFrame: FrameRequestCallback | null = null;
const requestAnimationFrame = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
previewFrame = callback;
return 77;
});
const cancelAnimationFrame = vi
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation(() => undefined);
try {
fireEvent.pointerDown(target!, {
pointerId: 88,
button: 0,
clientX: 20,
clientY: 30,
});
for (let index = 0; index < 100; index += 1) {
fireEvent.pointerMove(target!, {
pointerId: 88,
clientX: 40 + index,
clientY: 60 + index,
});
}
act(() => previewFrame?.(16));
expect(commits).toHaveLength(1);
expect(target?.getAttribute('style')).toContain('--resource-x: 119px');
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
} finally {
fireEvent.pointerCancel(target!, {
pointerId: 88,
clientX: 139,
clientY: 159,
});
requestAnimationFrame.mockRestore();
cancelAnimationFrame.mockRestore();
}
},
15_000,
);
it('persists a resource position with CAS and restores it after remount', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
@@ -1058,6 +1292,9 @@ export function registerProjectWorkbenchFoundationTests() {
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return structuredClone(persistedLayout);
}
@@ -1097,7 +1334,7 @@ export function registerProjectWorkbenchFoundationTests() {
}
renderWorkbench();
const card = screen.getByText('布局持久化回执').closest('button');
const card = (await screen.findByText('布局持久化回执')).closest('button');
expect(card).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
@@ -1142,7 +1379,9 @@ export function registerProjectWorkbenchFoundationTests() {
cleanup();
renderWorkbench();
const restoredCard = screen.getByText('布局持久化回执').closest('button');
const restoredCard = (
await screen.findByText('布局持久化回执')
).closest('button');
await waitFor(() => {
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-x: 92px',
@@ -1174,7 +1413,11 @@ export function registerProjectWorkbenchFoundationTests() {
],
updatedAt: 400,
};
const invoke = vi.fn(async (command: string) => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
...structuredClone(latestLayout),
@@ -1192,7 +1435,8 @@ export function registerProjectWorkbenchFoundationTests() {
return { status: 'conflict', layout: structuredClone(latestLayout) };
}
throw new Error(`unexpected invoke ${command}`);
});
},
);
window.__TAURI__ = { core: { invoke } };
render(
@@ -1214,7 +1458,7 @@ export function registerProjectWorkbenchFoundationTests() {
supervisor: React.createElement('div', null, '项目总控'),
}),
);
const card = screen.getByText('布局冲突回执').closest('button');
const card = (await screen.findByText('布局冲突回执')).closest('button');
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
});
@@ -1280,7 +1524,11 @@ export function registerProjectWorkbenchFoundationTests() {
}) => void)
| null = null;
let updateCalls = 0;
const invoke = vi.fn(async (command: string) => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
...structuredClone(latestLayout),
@@ -1295,7 +1543,8 @@ export function registerProjectWorkbenchFoundationTests() {
});
}
throw new Error(`unexpected invoke ${command}`);
});
},
);
window.__TAURI__ = { core: { invoke } };
render(
@@ -1317,7 +1566,7 @@ export function registerProjectWorkbenchFoundationTests() {
supervisor: React.createElement('div', null, '项目总控'),
}),
);
const card = screen.getByText('排队冲突回执').closest('button');
const card = (await screen.findByText('排队冲突回执')).closest('button');
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
});
@@ -1391,6 +1640,9 @@ export function registerProjectWorkbenchFoundationTests() {
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
const mode = args?.mode as 'dependency' | 'type';
if (command === 'read_local_project_resource_canvas_layout') {
return {
@@ -1464,7 +1716,11 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
const invoke = vi.fn(async (command: string) => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
@@ -1479,7 +1735,8 @@ export function registerProjectWorkbenchFoundationTests() {
throw new Error('disk full');
}
throw new Error(`unexpected invoke ${command}`);
});
},
);
window.__TAURI__ = { core: { invoke } };
render(
@@ -132,9 +132,21 @@ describe('resource dependency graph model', () => {
readModel({
resourceIds: ['asset:spec', 'asset:ui'],
producerAssignments: [
{ resourceId: 'asset:spec', taskId: 'art-director' },
{ resourceId: 'asset:ui', taskId: 'design-foundation' },
{ resourceId: 'asset:deleted', taskId: 'task-1' },
{
resourceId: 'asset:spec',
taskId: 'art-director',
dependencyDepth: 0,
},
{
resourceId: 'asset:ui',
taskId: 'design-foundation',
dependencyDepth: 1,
},
{
resourceId: 'asset:deleted',
taskId: 'task-1',
dependencyDepth: 99,
},
],
producerMappingTruncated: true,
}),
@@ -146,6 +158,12 @@ describe('resource dependency graph model', () => {
['asset:ui', 'design-foundation'],
]),
);
expect(graph.dependencyDepthByResourceId).toEqual(
new Map([
['asset:spec', 0],
['asset:ui', 1],
]),
);
expect(graph.producerMappingTruncated).toBe(true);
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
upstreamResourceIds: new Set(),
@@ -58,6 +58,17 @@ function position(
};
}
function automaticPosition(
resourceId: string,
x: number,
y: number,
): ProjectResourceCanvasPosition {
return {
...position(resourceId, x, y),
manuallyPlaced: false,
};
}
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
@@ -72,6 +83,117 @@ describe('useProjectResourceCanvasLayout', () => {
);
});
it('waits for dependency graph initialization before reading or writing layout', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 0, []);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('dependency', 1, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const shallowResource = resource('resource-a');
const deepResource = { ...shallowResource, dependencyDepth: 2 };
const { result, rerender } = renderHook(
({ initializationReady, resources }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
initializationReady,
rederiveAutomaticPositions: true,
}),
{
initialProps: {
initializationReady: false,
resources: [shallowResource],
},
},
);
expect(invoke).not.toHaveBeenCalled();
expect(result.current.layout.positions).toEqual([]);
rerender({ initializationReady: true, resources: [deepResource] });
await waitFor(() => expect(updates).toHaveLength(1));
expect(result.current.layout.positions[0]).toMatchObject({
resourceId: 'resource-a',
x: 392,
manuallyPlaced: false,
});
expect(invoke.mock.calls[0]?.[0]).toBe(
'read_local_project_resource_canvas_layout',
);
});
it('rederives automatic dependency positions while preserving manual positions', async () => {
const resourceA = { ...resource('resource-a'), dependencyDepth: 2 };
const resourceB = resource('resource-b');
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 4, [
automaticPosition('resource-a', 0, 0),
position('resource-b', 600, 40),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated',
layout: persistedLayout('dependency', 5, positions),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA, resourceB],
rederiveAutomaticPositions: true,
}),
);
await waitFor(() => expect(updates).toHaveLength(1));
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-a',
x: 392,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-b',
x: 600,
y: 40,
manuallyPlaced: true,
}),
]),
);
});
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(async (command: string) => {
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-07-31`
更新时间:`2026-08-03`
## 1. 产品定位
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
实现状态(2026-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
@@ -237,10 +237,10 @@ type UpdateProjectResourceCanvasLayoutResult =
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact``attachment``agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。
- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
@@ -250,7 +250,7 @@ type UpdateProjectResourceCanvasLayoutResult =
#### 5.2.5 资源依赖关系图层
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust。
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate``assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifestExternal Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
@@ -259,7 +259,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
- Pointer Move 必须按动画帧合并基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
### 5.3 资源类型与替换兼容性(P1)
@@ -405,6 +405,7 @@ type ProjectAgentMudPointAttribution = {
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
## 8. 非目标
@@ -1,5 +1,12 @@
# 决策记录
## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state
- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。
- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depthdependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。
- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDBtype 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。
- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
## 记录格式
@@ -22,6 +22,14 @@
- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除审计后橙色引用保留、灰色任务流消失。AppSurface 使用相同生产数据形状回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs``apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 依赖图未就绪时不能先初始化资源布局
- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。
- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。
- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。
- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`
## Jenkins 异步备份不能用 nohup 脱离作业
- 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。
@@ -396,6 +396,10 @@ game-project/
- 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`。搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度。
2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。
拖动热路径不再把 preview 写入工作台父组件 state。卡片使用稳定回调与 `React.memo`,动画帧直接更新拖动卡片 CSS 变量和 section 临时 extentSVG 图层暴露命令式 preview 句柄,复用 Rust 局部连接索引和已缓存 path 节点,只更新受影响 reference / task-flow 几何。ResizeObserver 仍为单图层单实例。4096 张真实卡片测试必须证明非拖动卡片零重渲染、静态 SVG 子树不重建,Chromium p95 继续以 `<16.7ms` 为门槛。实时 DOM 几何不得通过 Tauri IPC 往返 Rust。
## 分阶段实施
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。