实现资源依赖关系图层
Project CI / Frontend tests (pull_request) Successful in 2m40s
Project CI / Native shell tests (pull_request) Successful in 10m40s
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 7s

新增资源依赖图模型、去重、环检测与上下游查询。

使用原生 SVG 渲染资源引用和聚合任务流,并接入搜索、高亮、拖动及生命周期清理。

修复资源自引用连线被卡片遮挡的问题。

补充前端回归测试并同步工作台 PRD、技术方案和决策记录。
This commit is contained in:
2026-07-31 15:13:18 +08:00
parent 216407d93e
commit 8531a9af3e
10 changed files with 1615 additions and 115 deletions
+75 -2
View File
@@ -3871,8 +3871,6 @@ iframe.preview-frame {
.game-resource-canvas {
position: relative;
display: grid;
align-content: start;
flex: 1;
min-height: 0;
padding: 12px;
@@ -3882,7 +3880,73 @@ iframe.preview-frame {
background-size: 18px 18px;
}
.game-resource-canvas-content {
position: relative;
display: grid;
align-content: start;
width: max-content;
min-width: 100%;
min-height: 100%;
}
.game-resource-dependency-overlay {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.game-resource-dependency-edge,
.game-resource-dependency-edge path {
fill: none;
vector-effect: non-scaling-stroke;
transition:
opacity 140ms ease,
stroke-width 140ms ease;
}
.game-resource-dependency-edge--reference {
stroke: #d87342;
stroke-width: 2px;
}
.game-resource-dependency-edge--task,
.game-resource-dependency-edge--task path {
stroke: #9d948f;
stroke-width: 1.5px;
stroke-dasharray: 7 6;
}
.game-resource-dependency-edge.is-highlighted,
.game-resource-dependency-edge.is-highlighted path {
opacity: 1;
stroke-width: 2.8px;
}
.game-resource-dependency-edge.is-dimmed,
.game-resource-dependency-edge.is-dimmed path {
opacity: 0.14;
}
.game-resource-dependency-edge.is-cyclic,
.game-resource-dependency-edge.is-cyclic path {
stroke-dashoffset: 4;
}
.game-resource-dependency-marker--reference path {
fill: #d87342;
}
.game-resource-dependency-marker--task path {
fill: #9d948f;
}
.game-resource-section {
position: relative;
z-index: 1;
display: grid;
gap: 10px;
min-width: 620px;
@@ -3978,6 +4042,15 @@ iframe.preview-frame {
0 0 0 2px rgb(213 123 81 / 18%);
}
.game-resource-card.is-relation-upstream,
.game-resource-card.is-relation-downstream,
.game-resource-card.is-relation-both {
border-color: #d87342;
box-shadow:
0 8px 22px rgb(195 105 62 / 18%),
0 0 0 2px rgb(216 115 66 / 14%);
}
.game-resource-card-icon {
display: grid;
grid-row: 1 / 4;
@@ -0,0 +1,379 @@
import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type {
ProjectResourceCanvasPosition,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
} from './resourceCanvasLayoutModel';
import {
type ProjectResourceGraph,
projectResourceGraphNeighbors,
type ProjectResourceReferenceEdge,
type ProjectResourceTaskFlow,
} from './resourceDependencyGraphModel';
type Point = {
x: number;
y: number;
};
type Rect = Point & {
width: number;
height: number;
};
type SectionOrigins = Partial<Record<ProjectResourceCanvasSection, Point>>;
export type ResourceDependencyOverlayProps = {
graph: ProjectResourceGraph;
positions: readonly ProjectResourceCanvasPosition[];
visibleResourceIds: ReadonlySet<string>;
selectedResourceId: string | null;
};
const SECTION_SELECTOR = '[data-resource-section-plane]';
const TASK_FLOW_HUB_GAP = 20;
const SELF_REFERENCE_LOOP_WIDTH = 56;
const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18;
function pointsEqual(left: SectionOrigins, right: SectionOrigins) {
const sections: ProjectResourceCanvasSection[] = [
'document',
'version',
'art',
'audio',
];
return sections.every(
(section) =>
left[section]?.x === right[section]?.x &&
left[section]?.y === right[section]?.y,
);
}
function connectionPath(source: Point, target: Point) {
if (source.x === target.x && source.y === target.y) {
return `M ${source.x} ${source.y} C ${source.x + 48} ${source.y - 48}, ${
source.x + 48
} ${source.y + 48}, ${source.x} ${source.y + 1}`;
}
const direction = target.x >= source.x ? 1 : -1;
const bend = Math.max(36, Math.abs(target.x - source.x) * 0.45);
return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${
source.y
}, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`;
}
function linePath(source: Point, target: Point) {
const middleX = (source.x + target.x) / 2;
return `M ${source.x} ${source.y} L ${middleX} ${source.y} L ${middleX} ${
target.y
} L ${target.x} ${target.y}`;
}
function rectCenter(rect: Rect): Point {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
};
}
function average(values: readonly number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function rectAnchor(rect: Rect, direction: 1 | -1): Point {
return {
x: direction === 1 ? rect.x + rect.width : rect.x,
y: rect.y + rect.height / 2,
};
}
function referenceGeometry(
edge: ProjectResourceReferenceEdge,
rectByResourceId: ReadonlyMap<string, Rect>,
) {
const sourceRect = rectByResourceId.get(edge.sourceResourceId);
const targetRect = rectByResourceId.get(edge.targetResourceId);
if (!sourceRect || !targetRect) {
return null;
}
if (edge.sourceResourceId === edge.targetResourceId) {
const anchorX = sourceRect.x + sourceRect.width;
const centerY = sourceRect.y + sourceRect.height / 2;
const sourceY = centerY + SELF_REFERENCE_LOOP_ANCHOR_OFFSET;
const targetY = centerY - SELF_REFERENCE_LOOP_ANCHOR_OFFSET;
const loopX = anchorX + SELF_REFERENCE_LOOP_WIDTH;
return {
path: `M ${anchorX} ${sourceY} C ${loopX} ${sourceY}, ${loopX} ${targetY}, ${anchorX} ${targetY}`,
selfLoop: true,
};
}
const sourceCenter = rectCenter(sourceRect);
const targetCenter = rectCenter(targetRect);
const direction: 1 | -1 = targetCenter.x >= sourceCenter.x ? 1 : -1;
const source = rectAnchor(sourceRect, direction);
const target = rectAnchor(targetRect, direction === 1 ? -1 : 1);
return {
path: connectionPath(source, target),
selfLoop: false,
};
}
function taskFlowGeometry(
flow: ProjectResourceTaskFlow,
rectByResourceId: ReadonlyMap<string, Rect>,
) {
const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => {
const rect = rectByResourceId.get(resourceId);
return rect ? [{ resourceId, rect }] : [];
});
const targetRects = flow.targetResourceIds.flatMap((resourceId) => {
const rect = rectByResourceId.get(resourceId);
return rect ? [{ resourceId, rect }] : [];
});
if (sourceRects.length === 0 || targetRects.length === 0) {
return null;
}
const sourceCenterX = average(
sourceRects.map(({ rect }) => rectCenter(rect).x),
);
const targetCenterX = average(
targetRects.map(({ rect }) => rectCenter(rect).x),
);
const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1;
const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({
resourceId,
point: rectAnchor(rect, direction),
}));
const targetAnchors = targetRects.map(({ resourceId, rect }) => ({
resourceId,
point: rectAnchor(rect, direction === 1 ? -1 : 1),
}));
const sourceHub: Point = {
x:
(direction === 1
? Math.max(...sourceAnchors.map(({ point }) => point.x))
: Math.min(...sourceAnchors.map(({ point }) => point.x))) +
direction * TASK_FLOW_HUB_GAP,
y: average(sourceAnchors.map(({ point }) => point.y)),
};
const targetHub: Point = {
x:
(direction === 1
? Math.min(...targetAnchors.map(({ point }) => point.x))
: Math.max(...targetAnchors.map(({ point }) => point.x))) -
direction * TASK_FLOW_HUB_GAP,
y: average(targetAnchors.map(({ point }) => point.y)),
};
return { sourceAnchors, targetAnchors, sourceHub, targetHub };
}
export function ResourceDependencyOverlay({
graph,
positions,
visibleResourceIds,
selectedResourceId,
}: ResourceDependencyOverlayProps) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
const overlayRef = useRef<SVGSVGElement>(null);
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
useLayoutEffect(() => {
const canvas = overlayRef.current?.parentElement;
if (!canvas) {
return undefined;
}
let frameId: number | null = null;
const measure = () => {
frameId = null;
const canvasRect = canvas.getBoundingClientRect();
const next: SectionOrigins = {};
canvas
.querySelectorAll<HTMLElement>(SECTION_SELECTOR)
.forEach((plane) => {
const section = plane.dataset.resourceSectionPlane as
| ProjectResourceCanvasSection
| undefined;
if (!section) {
return;
}
const planeRect = plane.getBoundingClientRect();
next[section] = {
x: planeRect.left - canvasRect.left,
y: planeRect.top - canvasRect.top,
};
});
setSectionOrigins((current) =>
pointsEqual(current, next) ? current : next,
);
};
const scheduleMeasure = () => {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(measure);
};
measure();
const ResizeObserverClass = window.ResizeObserver;
const observer = ResizeObserverClass
? new ResizeObserverClass(scheduleMeasure)
: null;
observer?.observe(canvas);
canvas
.querySelectorAll<HTMLElement>(SECTION_SELECTOR)
.forEach((plane) => observer?.observe(plane));
window.addEventListener('resize', scheduleMeasure);
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
observer?.disconnect();
window.removeEventListener('resize', scheduleMeasure);
};
}, [graph, positions, visibleResourceIds]);
const rectByResourceId = useMemo(() => {
const result = new Map<string, Rect>();
for (const position of positions) {
if (
!graph.resourceIds.has(position.resourceId) ||
!visibleResourceIds.has(position.resourceId)
) {
continue;
}
const origin = sectionOrigins[position.section];
if (!origin) {
continue;
}
result.set(position.resourceId, {
x: origin.x + position.x,
y: origin.y + position.y,
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
});
}
return result;
}, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]);
const neighbors = useMemo(
() => projectResourceGraphNeighbors(graph, selectedResourceId),
[graph, selectedResourceId],
);
const selected = Boolean(
selectedResourceId && graph.resourceIds.has(selectedResourceId),
);
return (
<svg
ref={overlayRef}
className="game-resource-dependency-overlay"
data-testid="resource-dependency-overlay"
aria-hidden="true"
focusable="false"
>
<defs>
<marker
id={`${markerPrefix}-asset-reference-arrow`}
className="game-resource-dependency-marker game-resource-dependency-marker--reference"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 8 4 L 0 8 z" />
</marker>
<marker
id={`${markerPrefix}-task-flow-arrow`}
className="game-resource-dependency-marker game-resource-dependency-marker--task"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 8 4 L 0 8 z" />
</marker>
</defs>
{graph.taskFlows.map((flow) => {
const geometry = taskFlowGeometry(flow, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${flow.cyclic ? ' is-cyclic' : ''}`;
return (
<g
key={flow.id}
className={className}
data-edge-kind="task-flow"
data-edge-id={flow.id}
data-source-task-id={flow.sourceTaskId}
data-target-task-id={flow.targetTaskId}
data-cyclic={flow.cyclic || undefined}
>
<title>{`任务流转:${flow.sourceTaskId}${flow.targetTaskId}${
flow.cyclic ? '(检测到依赖环)' : ''
}`}</title>
{geometry.sourceAnchors.map(({ resourceId, point }) => (
<path
key={`source:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={linePath(point, geometry.sourceHub)}
/>
))}
<path
className="game-resource-dependency-trunk"
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
/>
{geometry.targetAnchors.map(({ resourceId, point }) => (
<path
key={`target:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={linePath(geometry.targetHub, point)}
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
/>
))}
</g>
);
})}
{graph.referenceEdges.map((edge) => {
const geometry = referenceGeometry(edge, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${edge.cyclic ? ' is-cyclic' : ''}`;
return (
<path
key={edge.id}
className={className}
data-edge-kind="asset-reference"
data-edge-id={edge.id}
data-source-resource-id={edge.sourceResourceId}
data-target-resource-id={edge.targetResourceId}
data-cyclic={edge.cyclic || undefined}
data-self-loop={geometry.selfLoop || undefined}
d={geometry.path}
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
>
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
</path>
);
})}
</svg>
);
}
@@ -46,6 +46,11 @@ import {
RESOURCE_CANVAS_DRAG_THRESHOLD,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import {
createProjectResourceGraph,
projectResourceGraphNeighbors,
} from './resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
type AttachmentResult = {
@@ -86,6 +91,9 @@ type ProjectResource = {
mediaType: string;
sourceLabel: string;
taskTitle: string | null;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: string[];
dependencies: string[];
dependencyDepth: number;
content?: string;
@@ -310,6 +318,9 @@ function resourcesFromProject(
mediaType: category === 'document' ? '项目文档' : '项目产物',
sourceLabel: '任务产物',
taskTitle: task.title,
producerTaskId: task.id,
externalResourceId: null,
referenceResourceIds: [],
dependencies: task.dependencies,
dependencyDepth: taskDependencyDepth(task, taskById),
});
@@ -341,6 +352,9 @@ function resourcesFromProject(
? 'Agent 生成'
: '用户上传',
taskTitle: task?.title ?? null,
producerTaskId: task?.id ?? null,
externalResourceId: asset.source.resourceId ?? null,
referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: task?.dependencies ?? [],
dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0,
});
@@ -362,6 +376,9 @@ function resourcesFromProject(
mediaType: attachment.mediaType || '未知媒体类型',
sourceLabel: '用户上传',
taskTitle: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
});
@@ -377,6 +394,9 @@ function resourcesFromProject(
mediaType: 'Agent 历史文本回执',
sourceLabel: `历史成果 · ${result.label}`,
taskTitle: null,
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
content: result.content,
@@ -440,6 +460,7 @@ function ResourceCard({
resource,
selected,
dragging,
relationState,
x,
y,
onSelect,
@@ -451,6 +472,7 @@ function ResourceCard({
resource: ProjectResource;
selected: boolean;
dragging: boolean;
relationState: 'upstream' | 'downstream' | 'both' | null;
x: number;
y: number;
onSelect: () => void;
@@ -465,7 +487,7 @@ function ResourceCard({
type="button"
className={`game-resource-card${selected ? ' is-selected' : ''}${
dragging ? ' is-dragging' : ''
}`}
}${relationState ? ` is-relation-${relationState}` : ''}`}
aria-pressed={selected}
data-resource-id={resource.id}
title="拖动调整资源位置"
@@ -562,16 +584,45 @@ export default function ProjectDevelopmentView({
const resourcePositionById = new Map(
resourceLayout.positions.map((position) => [position.resourceId, position]),
);
const resourceGraph = useMemo(
() => createProjectResourceGraph(resources, manifest.tasks),
[manifest.tasks, resources],
);
const selectedResourceNeighbors = useMemo(
() => projectResourceGraphNeighbors(resourceGraph, selectedResourceId),
[resourceGraph, selectedResourceId],
);
const normalizedSearch = searchText.trim().toLowerCase();
const visibleResources = resources.filter((resource) =>
normalizedSearch
? [
resource.label,
resource.path,
resource.mediaType,
resource.taskTitle ?? '',
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
const visibleResources = useMemo(
() =>
resources.filter((resource) =>
normalizedSearch
? [
resource.label,
resource.path,
resource.mediaType,
resource.taskTitle ?? '',
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
),
[normalizedSearch, resources],
);
const visibleResourceIds = useMemo(
() => new Set(visibleResources.map((resource) => resource.id)),
[visibleResources],
);
const resourceDependencyPositions = useMemo(
() =>
resourceLayout.positions.map((position) =>
resourceDragPreview?.resourceId === position.resourceId
? {
...position,
x: resourceDragPreview.x,
y: resourceDragPreview.y,
}
: position,
),
[resourceDragPreview, resourceLayout.positions],
);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
@@ -994,95 +1045,129 @@ export default function ProjectDevelopmentView({
}
aria-busy={resourceLayoutSaving}
>
{categoryOrder.map((category) => {
const categoryResources = visibleResources.filter(
(resource) => resource.category === category,
);
const categoryPositions = resourceLayout.positions.filter(
(position) => position.section === category,
);
const extent = resourceCanvasSectionExtent(
categoryPositions.map((position) =>
resourceDragPreview?.resourceId === position.resourceId
? {
...position,
x: resourceDragPreview.x,
y: resourceDragPreview.y,
}
: position,
),
);
const Icon = categoryIcons[category];
return (
<section
className="game-resource-section"
aria-label={categoryLabels[category]}
key={category}
>
<header>
<span>
<Icon size={16} aria-hidden="true" />
{categoryLabels[category]}
</span>
<small>{categoryResources.length}</small>
</header>
{categoryResources.length > 0 ? (
<div
className="game-resource-plane"
style={{
width: `${extent.width}px`,
height: `${extent.height}px`,
}}
>
{categoryResources.map((resource) => {
const position = resourcePositionById.get(
resource.id,
);
if (!position) {
return null;
<div className="game-resource-canvas-content">
{sortMode === 'dependency' ? (
<ResourceDependencyOverlay
key={`${projectPath}:${manifest.projectId}`}
graph={resourceGraph}
positions={resourceDependencyPositions}
visibleResourceIds={visibleResourceIds}
selectedResourceId={selectedResourceId}
/>
) : null}
{categoryOrder.map((category) => {
const categoryResources = visibleResources.filter(
(resource) => resource.category === category,
);
const categoryPositions = resourceLayout.positions.filter(
(position) => position.section === category,
);
const extent = resourceCanvasSectionExtent(
categoryPositions.map((position) =>
resourceDragPreview?.resourceId === position.resourceId
? {
...position,
x: resourceDragPreview.x,
y: resourceDragPreview.y,
}
const preview =
resourceDragPreview?.resourceId === resource.id
? resourceDragPreview
: null;
return (
<ResourceCard
key={resource.id}
resource={resource}
selected={resource.id === selectedResourceId}
dragging={resource.id === draggedResourceId}
x={preview?.x ?? position.x}
y={preview?.y ?? position.y}
onSelect={() => {
if (
suppressResourceClickRef.current ===
resource.id
) {
suppressResourceClickRef.current = null;
return;
: position,
),
);
const Icon = categoryIcons[category];
return (
<section
className="game-resource-section"
aria-label={categoryLabels[category]}
key={category}
>
<header>
<span>
<Icon size={16} aria-hidden="true" />
{categoryLabels[category]}
</span>
<small>{categoryResources.length}</small>
</header>
{categoryResources.length > 0 ? (
<div
className="game-resource-plane"
data-resource-section-plane={category}
style={{
width: `${extent.width}px`,
height: `${extent.height}px`,
}}
>
{categoryResources.map((resource) => {
const position = resourcePositionById.get(
resource.id,
);
if (!position) {
return null;
}
const preview =
resourceDragPreview?.resourceId === resource.id
? resourceDragPreview
: null;
const upstream =
sortMode === 'dependency' &&
selectedResourceNeighbors.upstreamResourceIds.has(
resource.id,
);
const downstream =
sortMode === 'dependency' &&
selectedResourceNeighbors.downstreamResourceIds.has(
resource.id,
);
const relationState =
upstream && downstream
? 'both'
: upstream
? 'upstream'
: downstream
? 'downstream'
: null;
return (
<ResourceCard
key={resource.id}
resource={resource}
selected={resource.id === selectedResourceId}
dragging={resource.id === draggedResourceId}
relationState={relationState}
x={preview?.x ?? position.x}
y={preview?.y ?? position.y}
onSelect={() => {
if (
suppressResourceClickRef.current ===
resource.id
) {
suppressResourceClickRef.current = null;
return;
}
setSelectedResourceId(resource.id);
}}
onPointerDown={(event) =>
handleResourceCardPointerDown(
event,
resource,
)
}
setSelectedResourceId(resource.id);
}}
onPointerDown={(event) =>
handleResourceCardPointerDown(event, resource)
}
onPointerMove={handleResourceCardPointerMove}
onPointerUp={(event) =>
handleResourceCardPointerEnd(event, false)
}
onPointerCancel={(event) =>
handleResourceCardPointerEnd(event, true)
}
/>
);
})}
</div>
) : (
<p></p>
)}
</section>
);
})}
onPointerMove={handleResourceCardPointerMove}
onPointerUp={(event) =>
handleResourceCardPointerEnd(event, false)
}
onPointerCancel={(event) =>
handleResourceCardPointerEnd(event, true)
}
/>
);
})}
</div>
) : (
<p></p>
)}
</section>
);
})}
</div>
</div>
</div>
) : (
@@ -0,0 +1,343 @@
export type ProjectResourceGraphNodeInput = {
id: string;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: readonly string[];
};
export type ProjectResourceGraphTaskInput = {
id: string;
dependencies: readonly string[];
};
export type ProjectResourceReferenceEdge = {
id: string;
kind: 'asset-reference';
sourceResourceId: string;
targetResourceId: string;
cyclic: boolean;
};
export type ProjectResourceTaskFlow = {
id: string;
kind: 'task-flow';
sourceTaskId: string;
targetTaskId: string;
sourceResourceIds: string[];
targetResourceIds: string[];
cyclic: boolean;
};
export type ProjectResourceGraph = {
resourceIds: ReadonlySet<string>;
referenceEdges: ProjectResourceReferenceEdge[];
taskFlows: ProjectResourceTaskFlow[];
unresolvedReferenceResourceIds: string[];
cyclicResourceIds: ReadonlySet<string>;
cyclicTaskIds: ReadonlySet<string>;
};
export type ProjectResourceGraphNeighbors = {
upstreamResourceIds: ReadonlySet<string>;
downstreamResourceIds: ReadonlySet<string>;
connectedEdgeIds: ReadonlySet<string>;
};
type DirectedEdge = {
id: string;
sourceId: string;
targetId: string;
};
type CycleAnalysis = {
cyclicNodeIds: Set<string>;
cyclicEdgeIds: Set<string>;
};
const emptyStringSet: ReadonlySet<string> = new Set<string>();
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
{
upstreamResourceIds: emptyStringSet,
downstreamResourceIds: emptyStringSet,
connectedEdgeIds: emptyStringSet,
};
function stableEdgeId(
kind: ProjectResourceReferenceEdge['kind'] | ProjectResourceTaskFlow['kind'],
sourceId: string,
targetId: string,
) {
return `${kind}:${JSON.stringify([sourceId, targetId])}`;
}
function uniqueSorted(values: Iterable<string>) {
return Array.from(new Set(values)).sort((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
}
function analyzeDirectedCycles(
nodeIds: Iterable<string>,
edges: readonly DirectedEdge[],
): CycleAnalysis {
const nodes = new Set(nodeIds);
for (const edge of edges) {
nodes.add(edge.sourceId);
nodes.add(edge.targetId);
}
const adjacency = new Map<string, string[]>();
const reverseAdjacency = new Map<string, string[]>();
for (const nodeId of nodes) {
adjacency.set(nodeId, []);
reverseAdjacency.set(nodeId, []);
}
for (const edge of edges) {
adjacency.get(edge.sourceId)?.push(edge.targetId);
reverseAdjacency.get(edge.targetId)?.push(edge.sourceId);
}
const visited = new Set<string>();
const finishOrder: string[] = [];
for (const root of nodes) {
if (visited.has(root)) {
continue;
}
visited.add(root);
const stack: Array<{ nodeId: string; nextIndex: number }> = [
{ nodeId: root, nextIndex: 0 },
];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
if (!frame) {
break;
}
const neighbors = adjacency.get(frame.nodeId) ?? [];
const next = neighbors[frame.nextIndex];
if (next === undefined) {
finishOrder.push(frame.nodeId);
stack.pop();
continue;
}
frame.nextIndex += 1;
if (!visited.has(next)) {
visited.add(next);
stack.push({ nodeId: next, nextIndex: 0 });
}
}
}
const componentByNode = new Map<string, number>();
const componentSizes: number[] = [];
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
const root = finishOrder[index];
if (root === undefined || componentByNode.has(root)) {
continue;
}
const componentId = componentSizes.length;
let size = 0;
const stack = [root];
componentByNode.set(root, componentId);
while (stack.length > 0) {
const nodeId = stack.pop();
if (nodeId === undefined) {
continue;
}
size += 1;
for (const neighbor of reverseAdjacency.get(nodeId) ?? []) {
if (!componentByNode.has(neighbor)) {
componentByNode.set(neighbor, componentId);
stack.push(neighbor);
}
}
}
componentSizes.push(size);
}
const cyclicNodeIds = new Set<string>();
const cyclicEdgeIds = new Set<string>();
for (const edge of edges) {
const sourceComponent = componentByNode.get(edge.sourceId);
const targetComponent = componentByNode.get(edge.targetId);
if (
sourceComponent !== undefined &&
sourceComponent === targetComponent &&
((componentSizes[sourceComponent] ?? 0) > 1 ||
edge.sourceId === edge.targetId)
) {
cyclicNodeIds.add(edge.sourceId);
cyclicNodeIds.add(edge.targetId);
cyclicEdgeIds.add(edge.id);
}
}
return { cyclicNodeIds, cyclicEdgeIds };
}
export function createProjectResourceGraph(
resources: readonly ProjectResourceGraphNodeInput[],
tasks: readonly ProjectResourceGraphTaskInput[],
): ProjectResourceGraph {
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const resourcesByTask = new Map<string, string[]>();
const resourcesByExternalId = new Map<string, string[]>();
for (const resource of resources) {
if (resource.producerTaskId) {
const taskResources = resourcesByTask.get(resource.producerTaskId) ?? [];
taskResources.push(resource.id);
resourcesByTask.set(resource.producerTaskId, taskResources);
}
if (resource.externalResourceId) {
const externalResources =
resourcesByExternalId.get(resource.externalResourceId) ?? [];
externalResources.push(resource.id);
resourcesByExternalId.set(resource.externalResourceId, externalResources);
}
}
for (const [taskId, resourceIds] of resourcesByTask) {
resourcesByTask.set(taskId, uniqueSorted(resourceIds));
}
const unresolvedReferenceResourceIds = new Set<string>();
const referenceEdgeById = new Map<string, ProjectResourceReferenceEdge>();
for (const target of resources) {
for (const externalReferenceId of new Set(target.referenceResourceIds)) {
const sourceCandidates =
resourcesByExternalId.get(externalReferenceId) ?? [];
if (sourceCandidates.length !== 1) {
unresolvedReferenceResourceIds.add(externalReferenceId);
continue;
}
const sourceResourceId = sourceCandidates[0];
if (sourceResourceId === undefined) {
unresolvedReferenceResourceIds.add(externalReferenceId);
continue;
}
if (!resourceById.has(sourceResourceId) || !resourceById.has(target.id)) {
continue;
}
const id = stableEdgeId('asset-reference', sourceResourceId, target.id);
referenceEdgeById.set(id, {
id,
kind: 'asset-reference',
sourceResourceId,
targetResourceId: target.id,
cyclic: false,
});
}
}
const referenceEdges = Array.from(referenceEdgeById.values()).sort(
(left, right) => left.id.localeCompare(right.id),
);
const referenceCycles = analyzeDirectedCycles(
resourceById.keys(),
referenceEdges.map((edge) => ({
id: edge.id,
sourceId: edge.sourceResourceId,
targetId: edge.targetResourceId,
})),
);
for (const edge of referenceEdges) {
edge.cyclic = referenceCycles.cyclicEdgeIds.has(edge.id);
}
const taskById = new Map(tasks.map((task) => [task.id, task]));
const taskDependencyEdges: DirectedEdge[] = [];
for (const targetTask of tasks) {
for (const sourceTaskId of new Set(targetTask.dependencies)) {
if (!taskById.has(sourceTaskId)) {
continue;
}
taskDependencyEdges.push({
id: stableEdgeId('task-flow', sourceTaskId, targetTask.id),
sourceId: sourceTaskId,
targetId: targetTask.id,
});
}
}
const taskCycles = analyzeDirectedCycles(
taskById.keys(),
taskDependencyEdges,
);
const taskFlowById = new Map<string, ProjectResourceTaskFlow>();
for (const targetTask of tasks) {
const targetResourceIds = resourcesByTask.get(targetTask.id) ?? [];
if (targetResourceIds.length === 0) {
continue;
}
for (const sourceTaskId of new Set(targetTask.dependencies)) {
if (!taskById.has(sourceTaskId)) {
continue;
}
const sourceResourceIds = resourcesByTask.get(sourceTaskId) ?? [];
if (sourceResourceIds.length === 0) {
continue;
}
const id = stableEdgeId('task-flow', sourceTaskId, targetTask.id);
taskFlowById.set(id, {
id,
kind: 'task-flow',
sourceTaskId,
targetTaskId: targetTask.id,
sourceResourceIds,
targetResourceIds,
cyclic: false,
});
}
}
const taskFlows = Array.from(taskFlowById.values()).sort((left, right) =>
left.id.localeCompare(right.id),
);
for (const flow of taskFlows) {
flow.cyclic = taskCycles.cyclicEdgeIds.has(flow.id);
}
return {
resourceIds: new Set(resourceById.keys()),
referenceEdges,
taskFlows,
unresolvedReferenceResourceIds: uniqueSorted(
unresolvedReferenceResourceIds,
),
cyclicResourceIds: referenceCycles.cyclicNodeIds,
cyclicTaskIds: taskCycles.cyclicNodeIds,
};
}
export function projectResourceGraphNeighbors(
graph: ProjectResourceGraph,
resourceId: string | null,
): ProjectResourceGraphNeighbors {
if (!resourceId || !graph.resourceIds.has(resourceId)) {
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
}
const upstreamResourceIds = new Set<string>();
const downstreamResourceIds = new Set<string>();
const connectedEdgeIds = new Set<string>();
for (const edge of graph.referenceEdges) {
if (edge.targetResourceId === resourceId) {
upstreamResourceIds.add(edge.sourceResourceId);
connectedEdgeIds.add(edge.id);
}
if (edge.sourceResourceId === resourceId) {
downstreamResourceIds.add(edge.targetResourceId);
connectedEdgeIds.add(edge.id);
}
}
for (const flow of graph.taskFlows) {
if (flow.targetResourceIds.includes(resourceId)) {
flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id));
connectedEdgeIds.add(flow.id);
}
if (flow.sourceResourceIds.includes(resourceId)) {
flow.targetResourceIds.forEach((id) => downstreamResourceIds.add(id));
connectedEdgeIds.add(flow.id);
}
}
upstreamResourceIds.delete(resourceId);
downstreamResourceIds.delete(resourceId);
return { upstreamResourceIds, downstreamResourceIds, connectedEdgeIds };
}
@@ -0,0 +1,263 @@
/** @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createProjectResourceGraph,
type ProjectResourceGraph,
} from '../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
function position(
resourceId: string,
x: number,
y: number,
): ProjectResourceCanvasPosition {
return {
resourceId,
section: 'art',
x,
y,
manuallyPlaced: false,
};
}
function graphFixture() {
return createProjectResourceGraph(
[
{
id: 'source:one',
producerTaskId: 'source-task',
externalResourceId: 'external-source',
referenceResourceIds: [],
},
...['source:two', 'source:three'].map((id) => ({
id,
producerTaskId: 'source-task',
externalResourceId: null,
referenceResourceIds: [],
})),
{
id: 'target:one',
producerTaskId: 'target-task',
externalResourceId: null,
referenceResourceIds: ['external-source'],
},
...['target:two', 'target:three'].map((id) => ({
id,
producerTaskId: 'target-task',
externalResourceId: null,
referenceResourceIds: [],
})),
{
id: 'unrelated',
producerTaskId: 'unrelated-task',
externalResourceId: 'external-unrelated',
referenceResourceIds: ['external-unrelated'],
},
],
[
{ id: 'source-task', dependencies: [] },
{ id: 'target-task', dependencies: ['source-task'] },
{ id: 'unrelated-task', dependencies: [] },
],
);
}
function OverlayHarness({
graph,
positions,
visibleResourceIds,
selectedResourceId = null,
}: {
graph: ProjectResourceGraph;
positions: ProjectResourceCanvasPosition[];
visibleResourceIds: ReadonlySet<string>;
selectedResourceId?: string | null;
}) {
return React.createElement(
'div',
null,
React.createElement('div', { 'data-resource-section-plane': 'art' }),
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
visibleResourceIds,
selectedResourceId,
}),
);
}
function overlayView(
graph: ProjectResourceGraph,
positions: ProjectResourceCanvasPosition[],
visibleResourceIds: ReadonlySet<string>,
selectedResourceId: string | null = null,
) {
return React.createElement(OverlayHarness, {
graph,
positions,
visibleResourceIds,
selectedResourceId,
});
}
describe('ResourceDependencyOverlay', () => {
it('renders exact references and one aggregated task trunk without cartesian paths', async () => {
const graph = graphFixture();
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
position(resourceId, (index % 3) * 220, Math.floor(index / 3) * 120),
);
render(overlayView(graph, positions, new Set(graph.resourceIds)));
const overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() =>
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(2),
);
const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]');
expect(taskFlow).not.toBeNull();
expect(taskFlow?.querySelectorAll('path')).toHaveLength(7);
expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3);
});
it('filters hidden endpoints and updates path geometry when positions change', async () => {
const graph = graphFixture();
const positions = [
position('source:one', 0, 0),
position('target:one', 240, 0),
];
const view = render(
overlayView(graph, positions, new Set(['source:one', 'target:one'])),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const firstPath = await waitFor(() => {
const path = overlay.querySelector<SVGPathElement>(
'[data-edge-kind="asset-reference"]',
);
expect(path).not.toBeNull();
return path?.getAttribute('d');
});
view.rerender(
overlayView(
graph,
[position('source:one', 80, 40), position('target:one', 240, 0)],
new Set(['source:one', 'target:one']),
),
);
await waitFor(() =>
expect(
overlay
.querySelector('[data-edge-kind="asset-reference"]')
?.getAttribute('d'),
).not.toBe(firstPath),
);
view.rerender(overlayView(graph, positions, new Set(['target:one'])));
await waitFor(() =>
expect(
overlay.querySelector('[data-edge-kind="asset-reference"]'),
).toBeNull(),
);
});
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
const graph = graphFixture();
const view = render(
overlayView(
graph,
[position('unrelated', 24, 32)],
new Set(['unrelated']),
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const selfLoopSelector =
'[data-edge-kind="asset-reference"]' +
'[data-source-resource-id="unrelated"]' +
'[data-target-resource-id="unrelated"]';
const selfLoop = await waitFor(() => {
const path = overlay.querySelector<SVGPathElement>(selfLoopSelector);
expect(path).not.toBeNull();
return path as SVGPathElement;
});
expect(selfLoop.getAttribute('data-cyclic')).toBe('true');
expect(selfLoop.getAttribute('data-self-loop')).toBe('true');
expect(selfLoop.getAttribute('d')).toBe(
'M 204 96 C 260 96, 260 60, 204 60',
);
expect(selfLoop.getAttribute('marker-end')).toContain(
'asset-reference-arrow',
);
view.rerender(
overlayView(
graph,
[position('unrelated', 84, 48)],
new Set(['unrelated']),
),
);
await waitFor(() =>
expect(selfLoop.getAttribute('d')).toBe(
'M 264 112 C 320 112, 320 76, 264 76',
),
);
});
it('highlights direct edges and dims unrelated cyclic edges for a selection', async () => {
const graph = graphFixture();
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
position(resourceId, index * 200, 0),
);
render(
overlayView(graph, positions, new Set(graph.resourceIds), 'target:one'),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() =>
expect(
overlay.querySelectorAll('.is-highlighted').length,
).toBeGreaterThan(0),
);
const unrelated = Array.from(
overlay.querySelectorAll<SVGElement>(
'[data-edge-kind="asset-reference"]',
),
).find((edge) => edge.getAttribute('data-cyclic') === 'true');
expect(unrelated?.classList.contains('is-dimmed')).toBe(true);
});
it('disconnects layout observers when the SVG layer is destroyed', () => {
const observe = vi.fn();
const disconnect = vi.fn();
class TestResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
observe = observe;
unobserve = vi.fn();
disconnect = disconnect;
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
try {
const graph = graphFixture();
const view = render(
overlayView(
graph,
Array.from(graph.resourceIds).map((resourceId, index) =>
position(resourceId, index * 200, 0),
),
new Set(graph.resourceIds),
),
);
expect(observe).toHaveBeenCalledTimes(2);
view.unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -359,6 +359,163 @@ export function registerProjectWorkbenchFoundationTests() {
expect(receiptDialog.querySelector('strong')).not.toBeNull();
});
it('renders, filters, highlights, moves, and destroys the resource dependency overlay', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
'资源依赖图测试',
);
manifest.assets.push(
{
id: 'dependency-spec',
kind: 'design-spec',
mediaType: 'application/json',
localPath: 'assets/spec-source.json',
source: {
kind: 'canvas',
taskId: 'art-director',
resourceId: 'canvas-spec-source',
},
},
{
id: 'dependency-ui',
kind: 'ui-prototype',
mediaType: 'application/json',
localPath: 'assets/ui-dependency.json',
source: {
kind: 'canvas',
taskId: 'design-foundation',
resourceId: 'canvas-ui-target',
referenceResourceIds: ['canvas-spec-source'],
},
},
{
id: 'unrelated-cycle',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/unrelated-cycle.json',
source: {
kind: 'canvas',
resourceId: 'canvas-unrelated',
referenceResourceIds: ['canvas-unrelated'],
},
},
);
const view = render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
let overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() => {
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(2);
expect(
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
).toHaveLength(1);
});
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(screen.queryByTestId('resource-dependency-overlay')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
overlay = await screen.findByTestId('resource-dependency-overlay');
const search = screen.getByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'ui-dependency' } });
await waitFor(() => {
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
});
fireEvent.change(search, { target: { value: '' } });
const sourceCard = screen.getByRole('button', {
name: /spec-source\.json/,
});
const targetCard = screen.getByRole('button', {
name: /ui-dependency\.json/,
});
const referenceSelector =
'[data-edge-kind="asset-reference"]' +
'[data-source-resource-id="asset:dependency-spec"]' +
'[data-target-resource-id="asset:dependency-ui"]';
const firstPath = await waitFor(() => {
const path = overlay.querySelector(referenceSelector);
expect(path).not.toBeNull();
return path?.getAttribute('d');
});
fireEvent.pointerDown(sourceCard, {
pointerId: 27,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
await waitFor(() => {
expect(
overlay.querySelector(referenceSelector)?.getAttribute('d'),
).not.toBe(firstPath);
});
fireEvent.pointerUp(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
fireEvent.click(targetCard);
await waitFor(() => {
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(true);
expect(
overlay
.querySelector(referenceSelector)
?.classList.contains('is-highlighted'),
).toBe(true);
});
expect(
overlay
.querySelector('[data-source-resource-id="asset:unrelated-cycle"]')
?.classList.contains('is-dimmed'),
).toBe(true);
const previousOverlay = overlay;
const nextManifest = createGameCreationAppManifest(
'workbench-resource-graph-next',
'新资源依赖图测试',
);
view.rerender(
React.createElement(ProjectDevelopmentView, {
projectName: '新资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph-next',
manifest: nextManifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const nextOverlay = await screen.findByTestId(
'resource-dependency-overlay',
);
expect(nextOverlay).not.toBe(previousOverlay);
expect(previousOverlay.isConnected).toBe(false);
expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull();
});
it('persists a resource position with CAS and restores it after remount', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import {
createProjectResourceGraph,
projectResourceGraphNeighbors,
type ProjectResourceGraphNodeInput,
} from '../src/view/project-development/resourceDependencyGraphModel';
function resource(
id: string,
producerTaskId: string | null,
options: Partial<ProjectResourceGraphNodeInput> = {},
): ProjectResourceGraphNodeInput {
return {
id,
producerTaskId,
externalResourceId: null,
referenceResourceIds: [],
...options,
};
}
describe('resource dependency graph model', () => {
it('maps exact external asset references, deduplicates them, and filters missing resources', () => {
const graph = createProjectResourceGraph(
[
resource('asset:spec', 'art-director', {
externalResourceId: 'canvas-resource-spec',
}),
resource('asset:ui', 'design-foundation', {
externalResourceId: 'canvas-resource-ui',
referenceResourceIds: [
'canvas-resource-spec',
'canvas-resource-spec',
'missing-resource',
],
}),
],
[
{ id: 'art-director', dependencies: [] },
{ id: 'design-foundation', dependencies: ['art-director'] },
],
);
expect(graph.referenceEdges).toEqual([
expect.objectContaining({
kind: 'asset-reference',
sourceResourceId: 'asset:spec',
targetResourceId: 'asset:ui',
cyclic: false,
}),
]);
expect(graph.unresolvedReferenceResourceIds).toEqual(['missing-resource']);
});
it('aggregates each task dependency into one flow instead of a resource cartesian product', () => {
const graph = createProjectResourceGraph(
[
resource('upstream:a', 'task-a'),
resource('upstream:b', 'task-a'),
resource('downstream:a', 'task-b'),
resource('downstream:b', 'task-b'),
resource('downstream:c', 'task-b'),
],
[
{ id: 'task-a', dependencies: [] },
{ id: 'task-b', dependencies: ['task-a', 'task-a'] },
],
);
expect(graph.taskFlows).toHaveLength(1);
expect(graph.taskFlows[0]).toMatchObject({
sourceTaskId: 'task-a',
targetTaskId: 'task-b',
sourceResourceIds: ['upstream:a', 'upstream:b'],
targetResourceIds: ['downstream:a', 'downstream:b', 'downstream:c'],
});
});
it('detects resource and task dependency cycles without recursive traversal', () => {
const graph = createProjectResourceGraph(
[
resource('asset:a', 'task-a', {
externalResourceId: 'external-a',
referenceResourceIds: ['external-b'],
}),
resource('asset:b', 'task-b', {
externalResourceId: 'external-b',
referenceResourceIds: ['external-a'],
}),
],
[
{ id: 'task-a', dependencies: ['task-b'] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:a', 'asset:b']));
expect(graph.referenceEdges.every((edge) => edge.cyclic)).toBe(true);
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
expect(graph.taskFlows.every((flow) => flow.cyclic)).toBe(true);
});
it('detects task cycles even when a cyclic task has no resource card', () => {
const graph = createProjectResourceGraph(
[resource('asset:a', 'task-a')],
[
{ id: 'task-a', dependencies: ['task-b'] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
expect(graph.taskFlows).toHaveLength(0);
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
});
it('returns direct upstream and downstream resources for exact and aggregated edges', () => {
const graph = createProjectResourceGraph(
[
resource('task-a:one', 'task-a', {
externalResourceId: 'external-a',
}),
resource('task-a:two', 'task-a'),
resource('task-b:one', 'task-b', {
referenceResourceIds: ['external-a'],
}),
],
[
{ id: 'task-a', dependencies: [] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
const neighbors = projectResourceGraphNeighbors(graph, 'task-b:one');
expect(neighbors.upstreamResourceIds).toEqual(
new Set(['task-a:one', 'task-a:two']),
);
expect(neighbors.downstreamResourceIds).toEqual(new Set());
expect(neighbors.connectedEdgeIds.size).toBe(2);
});
it('keeps graph construction linear for the supported resource bound', () => {
const resources = Array.from({ length: 4096 }, (_, index) =>
resource(`resource:${index}`, `task:${index}`),
);
const tasks = Array.from({ length: 4096 }, (_, index) => ({
id: `task:${index}`,
dependencies: index === 0 ? [] : [`task:${index - 1}`],
}));
const startedAt = performance.now();
const graph = createProjectResourceGraph(resources, tasks);
const elapsedMs = performance.now() - startedAt;
expect(graph.taskFlows).toHaveLength(4095);
expect(elapsedMs).toBeLessThan(2000);
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-07-28`
更新时间:`2026-07-31`
## 1. 产品定位
@@ -58,14 +58,14 @@
现有六个专业组为:
| group | 普通用户名称 | 当前职责 |
| --- | --- | --- |
| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 |
| `art` | 美术组 | 角色、场景、UI、动画和美术素材 |
| `code` | 程序组 | 可运行原型、模块实现和工程验证 |
| `balance` | 数值组 | 速度、生命、得分和难度参数 |
| `audio` | 音频组 | 背景音乐、音效和音频资源 |
| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 |
| group | 普通用户名称 | 当前职责 |
| ------------ | ------------ | ---------------------------------- |
| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 |
| `art` | 美术组 | 角色、场景、UI、动画和美术素材 |
| `code` | 程序组 | 可运行原型、模块实现和工程验证 |
| `balance` | 数值组 | 速度、生命、得分和难度参数 |
| `audio` | 音频组 | 背景音乐、音效和音频资源 |
| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 |
- 底栏默认突出策划、美术、程序三组。
- 允许在同一底栏展开数值、音频、发布组,不删除既有专业组。
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-07-28):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;关系线、资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式的前端资源依赖关系图层也已落地,但不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
@@ -247,6 +247,18 @@ type UpdateProjectResourceCanvasLayoutResult =
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
#### 5.2.5 资源依赖关系图层
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG 的 path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
- `asset-reference` 表示精确资源引用,使用橙色实线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制分支;禁止对上下游资源生成笛卡尔积连线。
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
### 5.3 资源类型与替换兼容性(P1)
```ts
@@ -382,9 +394,18 @@ type ProjectAgentMudPointAttribution = {
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。
### 7.3 P1 资源依赖关系图验收
1. dependency 模式同时正确显示橙色实线资源引用与灰色虚线任务流;type 模式没有图层或连线。
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
3. 多资源任务依赖只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线。
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
## 8. 非目标
- 资源画布布局持久化切片不实现资源关系线、资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因。
- 切片不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供前端派生展示,不建立新的资源业务真相
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -16,6 +16,15 @@
---
## 2026-07-31 AI 游戏创作资源依赖图采用纯前端派生 SVG
- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。
- 决策:dependency 模式从当前资源卡投影与 manifest task 派生 `ProjectResourceGraph`,使用原生 SVG 底层图层渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。图模型以迭代式强连通分量分析识别资源环和完整任务 DAG 环;搜索、选择、拖动只改变当前图层几何和高亮。
- 生命周期与边界:type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、ResizeObserver 和窗口监听。SVG 统一 `pointer-events: none`,不干扰卡片点击/拖动;path、marker、图结构和 section 原点从不持久化。本切片不修改 Rust、Tauri command、layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server 或 SpacetimeDB,也不引入第三方图表库。
- 影响范围:`apps/ai-game-creator-shell` 项目开发资源投影、依赖图模型、SVG overlay、样式与前端测试,以及工作台 PRD 和客户端实施计划。
- 验证方式:图模型与 SVG 定向测试覆盖去重、无效 ID、完整任务环、可见资源自引用闭环、任务流聚合、搜索过滤、选择高亮和拖动几何;AppSurface 覆盖两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-28 AI 游戏创作资源画布布局使用本地双模式 CAS sidecar
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
@@ -318,8 +318,8 @@ game-project/
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 只改变当前前端排列方式,不写回 manifest也不伪造资源依赖。
- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar画板编辑、生成关系连线、同类型版本资源替换仍不得在缺少各自正式写回契约时保存为业务事实。
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest不能推断或伪造缺失依赖。
- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar2026-07-31 起,dependency 模式增加不持久化的原生 SVG 关系图层。画板编辑和同类型版本资源替换仍不得在缺少各自正式写回契约时保存为业务事实。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
@@ -328,7 +328,7 @@ game-project/
- 当前独立 App 只交付横屏桌面工作台,Tauri `client` 默认与最小窗口统一为 `1280×800`;用户不能继续缩小到破坏双栏结构的窄屏尺寸。桌面工作台占满壳内剩余视口,四周只保留必要安全边距;右侧消息、Runtime 状态和输入区保持在同一栏内,专业状态过长时只滚动 Runtime 区,不得把输入区、底部 Agent 状态栏或整页撑出视口。`≤760px` 的浏览器样式仅保留开发兼容,不作为当前客户端交付口径。
- 项目总控失败摘要必须提供“在当前项目重试总控”的明确恢复动作并说明不会新建项目;旧父 run 下仍在运行的专业 Agent 继续展示真实状态。terminal 总控下不得单独重试专业 Agent,避免创建没有可交付父级的孤立委派;新总控 run 负责重新建立后续专业委派合同。
- 外部 Runner 模式下,重试命令的 Session Runtime 快照可能仍指向旧 run,因此响应必须额外返回精确 `acceptedRunId` 作为入队受理事实,前端据此锁定恢复按钮并持续同步该 run,不能用 `state.runId` 是否立即切换判断失败。同一 `agentId + sourceRunId` 已存在非终态 retry successor 时必须幂等复用并返回其 `acceptedRunId`,不得再次入队或追加第二条 retry audit。
- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端正式资源关系、前端版本替换真相或前端计费结论。
- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端版本替换真相或前端计费结论。资源关系图只能读取当前 manifest 与资源投影做派生展示,不得成为前端正式资源关系真相。
### 资源画布布局持久化 V1
@@ -341,7 +341,7 @@ game-project/
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须绑定当前 scope 保留,不得被后续资源协调成功、失败或通用提示定时器静默清除;新的手动布局成功保存或 scope 切换后才解除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入
- 本布局持久化切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线已在后续独立的纯前端切片接入,不改变本段 sidecar 合同;其余 P1 能力继续独立实施
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
@@ -349,6 +349,19 @@ game-project/
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。
### 资源依赖关系图层 V1
2026-07-31 起,项目工作台在不修改 Rust、layout sidecar 和既有布局模型的前提下增加纯前端资源依赖图层:
- `resourceDependencyGraphModel.ts` 负责从当前资源卡投影和 manifest task 构建 `ProjectResourceGraph`。精确引用把资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为实际卡片 ID;无匹配、多匹配和已删除资源只记录为 unresolved,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重。
- task flow 只读取存在于当前 manifest 的任务依赖。资源按 `producerTaskId` 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。
- 资源引用图和完整任务依赖图分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色虚线表示 `task-flow`;不引入 D3、React Flow 或其它图表依赖。
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片,并与普通边一样直接由拖动预览坐标重算。
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 与 window resize 只负责重新测量,并在卸载时清理。这样不依赖父组件 ref 在子组件 layout effect 中的绑定时序。type 模式不挂载图层;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
- 搜索可见 ID 在几何阶段过滤端点;选中资源只查询一跳上下游与关联边,关联卡片和边高亮,其余边弱化。拖动预览坐标直接替换当前卡片坐标传入图层,pointer move 实时更新 path;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
- 前端模型、SVG 单测和工作台 AppSurface 回归覆盖精确引用、去重、无效 ID、完整任务环、聚合复杂度、搜索过滤、选择高亮、拖动更新、type 模式卸载与项目切换销毁。该切片不修改 `resourceCanvasLayoutModel.ts`、Tauri command、layout sidecar、`api-server`、Rust 或 SpacetimeDB。
## 分阶段实施
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。
@@ -361,7 +374,7 @@ game-project/
- 用户能创建本地 Web 游戏项目。
- 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。
- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源;所有展示数据来自当前 manifest 或当前项目导入附件。
- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源;dependency 模式展示可验证的资源引用和聚合任务流,搜索、选择与拖动同步更新线段;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。
- 首个 `code-prototype` 任务未完成时运行入口不可进入并给出可感知提示;完成后可进入运行表现层,真实预览直接加载到客户端内受限运行容器。
- 审批档位通过独立弹出面板切换,默认严格审批;界面选择不得绕过 Runtime 现有确认门禁。
- 聊天输入 `/plan` 可在普通聊天消息里查看下一轮分工计划,不读取任务文件、不启动 run、不修改项目,也不新增普通用户计划面板。