diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 67a4f83f4..3ed2341dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) } +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8f72f2bcc..d400fadd3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1969,6 +1969,7 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2322,6 +2323,7 @@ fn main() { stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, + read_local_project_resource_graph, update_local_project_resource_canvas_layout, get_local_game_project_revision, get_local_game_manifest diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index aee2ea42a..1099d78e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_dependency_graph; mod resource_layout; mod verification; @@ -20,5 +21,6 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs new file mode 100644 index 000000000..54630f25e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,738 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphNodeInput { + pub resource_id: String, + #[serde(default)] + pub manifest_asset_id: Option, + #[serde(default)] + pub producer_task_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceReferenceEdge { + pub id: String, + pub kind: String, + pub source_resource_id: String, + pub target_resource_id: String, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceTaskFlow { + pub id: String, + pub kind: String, + pub source_task_id: String, + pub target_task_id: String, + pub source_resource_ids: Vec, + pub target_resource_ids: Vec, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceConnectionIndex { + pub resource_id: String, + pub upstream_reference_resource_ids: Vec, + pub downstream_reference_resource_ids: Vec, + pub reference_edge_ids: Vec, + pub task_flow_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceProducerAssignment { + pub resource_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphReadModel { + pub resource_ids: Vec, + pub reference_edges: Vec, + pub task_flows: Vec, + pub connection_index: Vec, + pub producer_assignments: Vec, + pub unresolved_reference_resource_ids: Vec, + pub cyclic_resource_ids: Vec, + pub cyclic_task_ids: Vec, + pub producer_mapping_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectedEdge { + id: String, + source_id: String, + target_id: String, +} + +#[derive(Debug, Default)] +struct CycleAnalysis { + cyclic_node_ids: BTreeSet, + cyclic_edge_ids: BTreeSet, +} + +#[derive(Debug, Default)] +struct MutableConnectionIndex { + upstream_reference_resource_ids: BTreeSet, + downstream_reference_resource_ids: BTreeSet, + reference_edge_ids: BTreeSet, + task_flow_ids: BTreeSet, +} + +fn stable_edge_id(kind: &str, source_id: &str, target_id: &str) -> String { + let pair = serde_json::to_string(&(source_id, target_id)) + .expect("serializing two resource graph identifiers cannot fail"); + format!("{kind}:{pair}") +} + +fn analyze_directed_cycles<'a>( + node_ids: impl IntoIterator, + edges: &[DirectedEdge], +) -> CycleAnalysis { + let mut nodes = node_ids.into_iter().cloned().collect::>(); + for edge in edges { + nodes.insert(edge.source_id.clone()); + nodes.insert(edge.target_id.clone()); + } + + let mut adjacency = nodes + .iter() + .map(|node_id| (node_id.clone(), Vec::::new())) + .collect::>(); + let mut reverse_adjacency = adjacency.clone(); + for edge in edges { + adjacency + .entry(edge.source_id.clone()) + .or_default() + .push(edge.target_id.clone()); + reverse_adjacency + .entry(edge.target_id.clone()) + .or_default() + .push(edge.source_id.clone()); + } + + let mut visited = BTreeSet::new(); + let mut finish_order = Vec::with_capacity(nodes.len()); + for root in &nodes { + if !visited.insert(root.clone()) { + continue; + } + let mut stack = vec![(root.clone(), 0usize)]; + while let Some((node_id, next_index)) = stack.last_mut() { + let neighbors = adjacency.get(node_id).map(Vec::as_slice).unwrap_or(&[]); + if let Some(next) = neighbors.get(*next_index) { + *next_index += 1; + if visited.insert(next.clone()) { + stack.push((next.clone(), 0)); + } + } else { + let completed = node_id.clone(); + stack.pop(); + finish_order.push(completed); + } + } + } + + let mut component_by_node = BTreeMap::::new(); + let mut component_sizes = Vec::::new(); + for root in finish_order.into_iter().rev() { + if component_by_node.contains_key(&root) { + continue; + } + let component_id = component_sizes.len(); + let mut size = 0usize; + let mut stack = vec![root.clone()]; + component_by_node.insert(root, component_id); + while let Some(node_id) = stack.pop() { + size += 1; + for neighbor in reverse_adjacency + .get(&node_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if !component_by_node.contains_key(neighbor) { + component_by_node.insert(neighbor.clone(), component_id); + stack.push(neighbor.clone()); + } + } + } + component_sizes.push(size); + } + + let mut result = CycleAnalysis::default(); + for edge in edges { + let source_component = component_by_node.get(&edge.source_id); + let target_component = component_by_node.get(&edge.target_id); + if source_component.is_some() + && source_component == target_component + && (component_sizes + .get(source_component.copied().unwrap_or_default()) + .copied() + .unwrap_or_default() + > 1 + || edge.source_id == edge.target_id) + { + result.cyclic_node_ids.insert(edge.source_id.clone()); + result.cyclic_node_ids.insert(edge.target_id.clone()); + result.cyclic_edge_ids.insert(edge.id.clone()); + } + } + result +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> BTreeMap { + let mut candidates = BTreeMap::>::new(); + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.canvas.asset_generate") + { + continue; + } + let Some(asset_id) = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(agent_id) = record + .get("agentId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| task_ids.contains(*value)) + else { + continue; + }; + candidates + .entry(asset_id.to_string()) + .or_default() + .insert(agent_id.to_string()); + } + candidates + .into_iter() + .filter_map(|(asset_id, agents)| { + (agents.len() == 1).then(|| (asset_id, agents.into_iter().next().unwrap_or_default())) + }) + .collect() +} + +pub(crate) fn build_project_resource_graph( + manifest: &GameCreationAppManifest, + resources: Vec, + agent_db_records: &[serde_json::Value], + producer_mapping_truncated: bool, +) -> ProjectResourceGraphReadModel { + let resource_by_id = resources + .into_iter() + .filter_map(|mut resource| { + resource.resource_id = resource.resource_id.trim().to_string(); + (!resource.resource_id.is_empty()).then_some((resource.resource_id.clone(), resource)) + }) + .collect::>(); + let task_by_id = manifest + .tasks + .iter() + .map(|task| (task.id.clone(), task)) + .collect::>(); + let task_ids = task_by_id.keys().cloned().collect::>(); + let manifest_asset_by_id = manifest + .assets + .iter() + .map(|asset| (asset.id.clone(), asset)) + .collect::>(); + let audit_producer_by_asset_id = audit_asset_producers(agent_db_records, &task_ids); + + let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); + for resource in resource_by_id.values() { + if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| manifest_asset_by_id.contains_key(*asset_id)) + { + resource_ids_by_manifest_asset + .entry(asset_id.to_string()) + .or_default() + .push(resource.resource_id.clone()); + } + } + + let mut producer_by_resource_id = BTreeMap::::new(); + for resource in resource_by_id.values() { + let producer = if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| { + resource_ids_by_manifest_asset + .get(*asset_id) + .is_some_and(|resource_ids| resource_ids.len() == 1) + }) { + audit_producer_by_asset_id.get(asset_id).cloned() + } else { + resource + .producer_task_id + .as_deref() + .map(str::trim) + .filter(|task_id| task_ids.contains(*task_id)) + .map(ToOwned::to_owned) + }; + if let Some(producer) = producer { + producer_by_resource_id.insert(resource.resource_id.clone(), producer); + } + } + + let mut resources_by_task = BTreeMap::>::new(); + for (resource_id, task_id) in &producer_by_resource_id { + resources_by_task + .entry(task_id.clone()) + .or_default() + .push(resource_id.clone()); + } + + let mut resources_by_external_id = BTreeMap::>::new(); + for (asset_id, resource_ids) in &resource_ids_by_manifest_asset { + if resource_ids.len() != 1 { + continue; + } + let Some(external_resource_id) = manifest_asset_by_id + .get(asset_id) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + resources_by_external_id + .entry(external_resource_id.to_string()) + .or_default() + .push(resource_ids[0].clone()); + } + + let mut unresolved_reference_resource_ids = BTreeSet::new(); + let mut reference_edge_by_id = BTreeMap::::new(); + for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset { + if target_resource_ids.len() != 1 { + continue; + } + let Some(asset) = manifest_asset_by_id.get(asset_id) else { + continue; + }; + let target_resource_id = &target_resource_ids[0]; + for external_reference_id in asset + .source + .reference_resource_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + { + let source_candidates = resources_by_external_id + .get(external_reference_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if source_candidates.len() != 1 { + unresolved_reference_resource_ids.insert(external_reference_id.to_string()); + continue; + } + let source_resource_id = &source_candidates[0]; + if !resource_by_id.contains_key(source_resource_id) + || !resource_by_id.contains_key(target_resource_id) + { + continue; + } + let id = stable_edge_id("asset-reference", source_resource_id, target_resource_id); + reference_edge_by_id.insert( + id.clone(), + ProjectResourceReferenceEdge { + id, + kind: "asset-reference".to_string(), + source_resource_id: source_resource_id.clone(), + target_resource_id: target_resource_id.clone(), + cyclic: false, + }, + ); + } + } + let reference_directed_edges = reference_edge_by_id + .values() + .map(|edge| DirectedEdge { + id: edge.id.clone(), + source_id: edge.source_resource_id.clone(), + target_id: edge.target_resource_id.clone(), + }) + .collect::>(); + let reference_cycles = + analyze_directed_cycles(resource_by_id.keys(), &reference_directed_edges); + let reference_edges = reference_edge_by_id + .into_values() + .map(|mut edge| { + edge.cyclic = reference_cycles.cyclic_edge_ids.contains(&edge.id); + edge + }) + .collect::>(); + + let task_dependency_edges = manifest + .tasks + .iter() + .flat_map(|target_task| { + target_task + .dependencies + .iter() + .collect::>() + .into_iter() + .filter(|source_task_id| task_by_id.contains_key(*source_task_id)) + .map(|source_task_id| DirectedEdge { + id: stable_edge_id("task-flow", source_task_id, &target_task.id), + source_id: source_task_id.clone(), + target_id: target_task.id.clone(), + }) + .collect::>() + }) + .collect::>(); + let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_flows = task_dependency_edges + .iter() + .filter_map(|edge| { + let source_resource_ids = resources_by_task.get(&edge.source_id)?; + let target_resource_ids = resources_by_task.get(&edge.target_id)?; + (!source_resource_ids.is_empty() && !target_resource_ids.is_empty()).then(|| { + ProjectResourceTaskFlow { + id: edge.id.clone(), + kind: "task-flow".to_string(), + source_task_id: edge.source_id.clone(), + target_task_id: edge.target_id.clone(), + source_resource_ids: source_resource_ids.clone(), + target_resource_ids: target_resource_ids.clone(), + cyclic: task_cycles.cyclic_edge_ids.contains(&edge.id), + } + }) + }) + .collect::>(); + + let mut connection_by_resource_id = resource_by_id + .keys() + .map(|resource_id| (resource_id.clone(), MutableConnectionIndex::default())) + .collect::>(); + for edge in &reference_edges { + if let Some(target) = connection_by_resource_id.get_mut(&edge.target_resource_id) { + target + .upstream_reference_resource_ids + .insert(edge.source_resource_id.clone()); + target.reference_edge_ids.insert(edge.id.clone()); + } + if let Some(source) = connection_by_resource_id.get_mut(&edge.source_resource_id) { + source + .downstream_reference_resource_ids + .insert(edge.target_resource_id.clone()); + source.reference_edge_ids.insert(edge.id.clone()); + } + } + for flow in &task_flows { + for resource_id in flow + .source_resource_ids + .iter() + .chain(flow.target_resource_ids.iter()) + { + if let Some(index) = connection_by_resource_id.get_mut(resource_id) { + index.task_flow_ids.insert(flow.id.clone()); + } + } + } + + ProjectResourceGraphReadModel { + resource_ids: resource_by_id.keys().cloned().collect(), + reference_edges, + task_flows, + connection_index: connection_by_resource_id + .into_iter() + .map(|(resource_id, index)| ProjectResourceConnectionIndex { + resource_id, + upstream_reference_resource_ids: index + .upstream_reference_resource_ids + .into_iter() + .collect(), + downstream_reference_resource_ids: index + .downstream_reference_resource_ids + .into_iter() + .collect(), + reference_edge_ids: index.reference_edge_ids.into_iter().collect(), + task_flow_ids: index.task_flow_ids.into_iter().collect(), + }) + .collect(), + producer_assignments: producer_by_resource_id + .into_iter() + .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { + resource_id, + task_id, + }) + .collect(), + unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), + cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), + cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), + producer_mapping_truncated, + } +} + +pub(crate) fn read_project_resource_graph_at( + root: &Path, + expected_project_id: &str, + resources: Vec, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("资源依赖图项目身份不匹配".to_string()); + } + let (records, truncated) = + read_agent_db_records_bounded(root, RESOURCE_GRAPH_AGENT_DB_READ_BYTES)?; + Ok(build_project_resource_graph( + &manifest, resources, &records, truncated, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource( + resource_id: &str, + manifest_asset_id: Option<&str>, + producer_task_id: Option<&str>, + ) -> ProjectResourceGraphNodeInput { + ProjectResourceGraphNodeInput { + resource_id: resource_id.to_string(), + manifest_asset_id: manifest_asset_id.map(ToOwned::to_owned), + producer_task_id: producer_task_id.map(ToOwned::to_owned), + } + } + + fn asset( + id: &str, + external_resource_id: Option<&str>, + references: &[&str], + external_task_id: Option<&str>, + ) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "test".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{id}.png"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: external_resource_id.map(ToOwned::to_owned), + asset_object_id: None, + task_id: external_task_id.map(ToOwned::to_owned), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: references.iter().map(|value| value.to_string()).collect(), + }, + } + } + + fn task(id: &str, dependencies: &[&str]) -> GameCreationAppTaskState { + GameCreationAppTaskState { + id: id.to_string(), + title: id.to_string(), + group: GameCreationAppAgentGroup::Art, + role: "test".to_string(), + dependencies: dependencies.iter().map(|value| value.to_string()).collect(), + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + } + + fn manifest( + tasks: Vec, + assets: Vec, + ) -> GameCreationAppManifest { + let mut manifest = new_game_creation_app_manifest("graph-project", "Graph project"); + manifest.tasks = tasks; + manifest.assets = assets; + manifest + } + + #[test] + fn graph_uses_runtime_agent_identity_instead_of_external_task_id() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let records = vec![ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + 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!(graph + .producer_assignments + .iter() + .all(|assignment| assignment.task_id != "task-1" && assignment.task_id != "task-2")); + } + + #[test] + fn graph_omits_task_flow_without_reliable_runtime_producer_evidence() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("art-director")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("design-foundation"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[], + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert!(graph.task_flows.is_empty()); + assert!(graph.producer_assignments.is_empty()); + } + + #[test] + fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { + let manifest = manifest( + vec![task("task-a", &["task-b"]), task("task-b", &["task-a"])], + vec![ + asset( + "a", + Some("external-a"), + &["external-b", "missing"], + Some("task-1"), + ), + asset("b", Some("external-b"), &["external-a"], Some("task-2")), + ], + ); + let records = vec![ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "a", "agentId": "task-a"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "b", "agentId": "task-b"}), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:a", Some("a"), None), + resource("asset:b", Some("b"), None), + resource("task-a:artifact", None, Some("task-a")), + resource("task-b:artifact", None, Some("task-b")), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 2); + assert!(graph.reference_edges.iter().all(|edge| edge.cyclic)); + assert_eq!(graph.task_flows.len(), 2); + assert!(graph.task_flows.iter().all(|flow| flow.cyclic)); + assert_eq!(graph.unresolved_reference_resource_ids, vec!["missing"]); + assert_eq!(graph.cyclic_resource_ids, vec!["asset:a", "asset:b"]); + assert_eq!(graph.cyclic_task_ids, vec!["task-a", "task-b"]); + assert!( + graph + .task_flows + .iter() + .all(|flow| flow.source_resource_ids.len() == 2 + && flow.target_resource_ids.len() == 2) + ); + } + + #[test] + fn graph_handles_4096_task_chain_without_recursive_traversal_or_cartesian_edges() { + let tasks = (0..4096) + .map(|index| { + let id = format!("task:{index}"); + let dependencies = if index == 0 { + Vec::new() + } else { + vec![format!("task:{}", index - 1)] + }; + GameCreationAppTaskState { + id: id.clone(), + title: id, + group: GameCreationAppAgentGroup::Code, + role: "test".to_string(), + dependencies, + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + }) + .collect::>(); + let resources = (0..4096) + .map(|index| { + resource( + &format!("resource:{index}"), + None, + Some(&format!("task:{index}")), + ) + }) + .collect::>(); + let graph = + build_project_resource_graph(&manifest(tasks, Vec::new()), resources, &[], false); + + assert_eq!(graph.task_flows.len(), 4095); + assert_eq!(graph.connection_index.len(), 4096); + assert!(graph + .connection_index + .iter() + .all(|index| index.task_flow_ids.len() <= 2)); + } +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index a3ffc1ab4..238abba58 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -27,9 +27,14 @@ type Rect = Point & { type SectionOrigins = Partial>; +type RectLookup = { + get(resourceId: string): Rect | undefined; +}; + export type ResourceDependencyOverlayProps = { graph: ProjectResourceGraph; positions: readonly ProjectResourceCanvasPosition[]; + dragPreview: (Point & { resourceId: string }) | null; visibleResourceIds: ReadonlySet; selectedResourceId: string | null; }; @@ -117,7 +122,7 @@ function rectAnchor(rect: Rect, direction: 1 | -1): Point { function referenceGeometry( edge: ProjectResourceReferenceEdge, - rectByResourceId: ReadonlyMap, + rectByResourceId: RectLookup, ) { const sourceRect = rectByResourceId.get(edge.sourceResourceId); const targetRect = rectByResourceId.get(edge.targetResourceId); @@ -148,7 +153,7 @@ function referenceGeometry( function taskFlowGeometry( flow: ProjectResourceTaskFlow, - rectByResourceId: ReadonlyMap, + rectByResourceId: RectLookup, ) { const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => { const rect = rectByResourceId.get(resourceId); @@ -198,11 +203,15 @@ function taskFlowGeometry( export function ResourceDependencyOverlay({ graph, positions, + dragPreview, visibleResourceIds, selectedResourceId, }: ResourceDependencyOverlayProps) { const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); const overlayRef = useRef(null); + const referencePathRefs = useRef(new Map()); + const taskFlowGroupRefs = useRef(new Map()); + const activeDragResourceIdRef = useRef(null); const [sectionOrigins, setSectionOrigins] = useState({}); useLayoutEffect(() => { @@ -257,7 +266,7 @@ export function ResourceDependencyOverlay({ observer?.disconnect(); window.removeEventListener('resize', scheduleMeasure); }; - }, [graph, positions, visibleResourceIds]); + }, []); const rectByResourceId = useMemo(() => { const result = new Map(); @@ -290,6 +299,216 @@ export function ResourceDependencyOverlay({ selectedResourceId && graph.resourceIds.has(selectedResourceId), ); + const renderTaskFlows = useMemo( + () => + 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 ( + { + if (node) { + taskFlowGroupRefs.current.set(flow.id, node); + } else { + taskFlowGroupRefs.current.delete(flow.id); + } + }} + 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} + > + {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ + flow.cyclic ? '(检测到依赖环)' : '' + }`} + {geometry.sourceAnchors.map(({ resourceId, point }) => ( + + ))} + + {geometry.targetAnchors.map(({ resourceId, point }) => ( + + ))} + + ); + }), + [ + graph.taskFlows, + markerPrefix, + neighbors.connectedEdgeIds, + rectByResourceId, + selected, + ], + ); + + const renderReferenceEdges = useMemo( + () => + 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 ( + { + if (node) { + referencePathRefs.current.set(edge.id, node); + } else { + referencePathRefs.current.delete(edge.id); + } + }} + 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)`} + > + {`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`} + + ); + }), + [ + graph.referenceEdges, + markerPrefix, + neighbors.connectedEdgeIds, + rectByResourceId, + selected, + ], + ); + + useLayoutEffect(() => { + const affectedResourceIds = new Set(); + if (activeDragResourceIdRef.current) { + affectedResourceIds.add(activeDragResourceIdRef.current); + } + if (dragPreview) { + affectedResourceIds.add(dragPreview.resourceId); + } + activeDragResourceIdRef.current = dragPreview?.resourceId ?? null; + if (affectedResourceIds.size === 0) { + return; + } + const dragBasePosition = dragPreview + ? positions.find( + (position) => position.resourceId === dragPreview.resourceId, + ) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = rectByResourceId.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, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = graph.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); + if (referenceEdge) { + const geometry = referenceGeometry(referenceEdge, rectLookup); + const path = referencePathRefs.current.get(edgeId); + if (geometry && path) { + path.setAttribute('d', geometry.path); + } + continue; + } + const flow = graph.taskFlowById.get(edgeId); + const group = taskFlowGroupRefs.current.get(edgeId); + if (!flow || !group) { + continue; + } + const geometry = taskFlowGeometry(flow, rectLookup); + if (!geometry) { + continue; + } + const sourcePathByResourceId = new Map(); + const targetPathByResourceId = new Map(); + group + .querySelectorAll('.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 + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + group + .querySelector('.game-resource-dependency-trunk') + ?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); + geometry.targetAnchors.forEach(({ resourceId, point }) => { + targetPathByResourceId + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + }, [dragPreview, graph, positions, rectByResourceId]); + return ( - {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 ( - - {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ - flow.cyclic ? '(检测到依赖环)' : '' - }`} - {geometry.sourceAnchors.map(({ resourceId, point }) => ( - - ))} - - {geometry.targetAnchors.map(({ resourceId, point }) => ( - - ))} - - ); - })} - - {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 ( - - {`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`} - - ); - })} + {renderTaskFlows} + {renderReferenceEdges} ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index b391a4a74..3d8ed61bc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -43,12 +43,20 @@ import { resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, + RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DRAG_THRESHOLD, + RESOURCE_CANVAS_ROW_GAP, resourceCanvasSectionExtent, } from './resourceCanvasLayoutModel'; import { - createProjectResourceGraph, + EMPTY_PROJECT_RESOURCE_GRAPH, + normalizeProjectResourceGraph, + type ProjectResourceGraph, projectResourceGraphNeighbors, + type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, } from './resourceDependencyGraphModel'; import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; @@ -91,6 +99,7 @@ type ProjectResource = { mediaType: string; sourceLabel: string; taskTitle: string | null; + manifestAssetId: string | null; producerTaskId: string | null; externalResourceId: string | null; referenceResourceIds: string[]; @@ -139,6 +148,7 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; +const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; @@ -318,6 +328,7 @@ function resourcesFromProject( mediaType: category === 'document' ? '项目文档' : '项目产物', sourceLabel: '任务产物', taskTitle: task.title, + manifestAssetId: null, producerTaskId: task.id, externalResourceId: null, referenceResourceIds: [], @@ -328,9 +339,6 @@ function resourcesFromProject( } for (const asset of manifest.assets) { - const task = asset.source.taskId - ? taskById.get(asset.source.taskId) - : undefined; const isPendingUiPrototype = asset.kind === 'ui-prototype' && taskById.get('design-foundation')?.status !== 'completed'; @@ -351,12 +359,13 @@ function resourcesFromProject( : asset.source.kind === 'generated' ? 'Agent 生成' : '用户上传', - taskTitle: task?.title ?? null, - producerTaskId: task?.id ?? null, + taskTitle: null, + manifestAssetId: asset.id, + producerTaskId: null, externalResourceId: asset.source.resourceId ?? null, referenceResourceIds: asset.source.referenceResourceIds ?? [], - dependencies: task?.dependencies ?? [], - dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0, + dependencies: [], + dependencyDepth: 0, }); } @@ -376,6 +385,7 @@ function resourcesFromProject( mediaType: attachment.mediaType || '未知媒体类型', sourceLabel: '用户上传', taskTitle: null, + manifestAssetId: null, producerTaskId: null, externalResourceId: null, referenceResourceIds: [], @@ -394,6 +404,7 @@ function resourcesFromProject( mediaType: 'Agent 历史文本回执', sourceLabel: `历史成果 · ${result.label}`, taskTitle: null, + manifestAssetId: null, producerTaskId: taskById.has(result.agentId) ? result.agentId : null, externalResourceId: null, referenceResourceIds: [], @@ -552,6 +563,10 @@ export default function ProjectDevelopmentView({ const dockRef = useRef(null); const resourceDialogRef = useRef(null); const resourceCardDragRef = useRef(null); + const pendingResourceDragPreviewRef = useRef< + (Point & { resourceId: string }) | null + >(null); + const resourceDragFrameRef = useRef(null); const suppressResourceClickRef = useRef(null); const resourceDialogDragRef = useRef<{ pointerId: number; @@ -566,10 +581,121 @@ export default function ProjectDevelopmentView({ manifest.tasks.some( (task) => task.id === 'code-prototype' && task.status === 'completed', ); - const resources = useMemo( + const projectedResources = useMemo( () => resourcesFromProject(manifest, attachments, agentResults), [agentResults, attachments, manifest], ); + const resourceGraphInputs = useMemo( + () => + projectedResources.map((resource) => ({ + resourceId: resource.id, + manifestAssetId: resource.manifestAssetId, + producerTaskId: resource.producerTaskId, + })), + [projectedResources], + ); + const resourceGraphScopeKey = useMemo( + () => + JSON.stringify([ + projectPath, + manifest.projectId, + resourceGraphInputs, + ]), + [manifest.projectId, projectPath, resourceGraphInputs], + ); + const [resourceGraphState, setResourceGraphState] = useState<{ + scopeKey: string; + graph: ProjectResourceGraph; + }>({ scopeKey: '', graph: EMPTY_PROJECT_RESOURCE_GRAPH }); + + useEffect(() => { + let cancelled = false; + if (sortMode !== 'dependency') { + setResourceGraphState({ + scopeKey: '', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) + .then((readModel) => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + graph: normalizeProjectResourceGraph(readModel), + }); + } + }) + .catch(() => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + manifest.projectId, + projectPath, + resourceGraphInputs, + resourceGraphScopeKey, + sortMode, + ]); + + const resourceGraph = + resourceGraphState.scopeKey === resourceGraphScopeKey + ? resourceGraphState.graph + : EMPTY_PROJECT_RESOURCE_GRAPH; + const manifestTaskById = useMemo( + () => new Map(manifest.tasks.map((task) => [task.id, task])), + [manifest.tasks], + ); + const resources = useMemo( + () => + projectedResources.map((resource) => { + const producerTaskId = + resourceGraph.producerTaskIdByResourceId.get(resource.id) ?? + resource.producerTaskId; + const producerTask = producerTaskId + ? manifestTaskById.get(producerTaskId) + : undefined; + return producerTask && producerTaskId !== resource.producerTaskId + ? { + ...resource, + taskTitle: producerTask.title, + producerTaskId, + dependencies: producerTask.dependencies, + dependencyDepth: taskDependencyDepth( + producerTask, + manifestTaskById, + ), + } + : resource; + }), + [manifestTaskById, projectedResources, resourceGraph], + ); const { layout: resourceLayout, notice: resourceLayoutNotice, @@ -581,12 +707,15 @@ export default function ProjectDevelopmentView({ mode: sortMode, resources, }); - const resourcePositionById = new Map( - resourceLayout.positions.map((position) => [position.resourceId, position]), - ); - const resourceGraph = useMemo( - () => createProjectResourceGraph(resources, manifest.tasks), - [manifest.tasks, resources], + const resourcePositionById = useMemo( + () => + new Map( + resourceLayout.positions.map((position) => [ + position.resourceId, + position, + ]), + ), + [resourceLayout.positions], ); const selectedResourceNeighbors = useMemo( () => projectResourceGraphNeighbors(resourceGraph, selectedResourceId), @@ -611,18 +740,41 @@ export default function ProjectDevelopmentView({ () => new Set(visibleResources.map((resource) => resource.id)), [visibleResources], ); - const resourceDependencyPositions = useMemo( + const visibleResourcesByCategory = useMemo( () => - resourceLayout.positions.map((position) => - resourceDragPreview?.resourceId === position.resourceId - ? { - ...position, - x: resourceDragPreview.x, - y: resourceDragPreview.y, - } - : position, + new Map( + categoryOrder.map((category) => [ + category, + visibleResources.filter( + (resource) => resource.category === category, + ), + ]), ), - [resourceDragPreview, resourceLayout.positions], + [visibleResources], + ); + const resourcePositionsByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceLayout.positions.filter( + (position) => position.section === category, + ), + ]), + ), + [resourceLayout.positions], + ); + const resourceBaseExtentByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceCanvasSectionExtent( + resourcePositionsByCategory.get(category) ?? [], + ), + ]), + ), + [resourcePositionsByCategory], ); const selectedResource = resources.find((resource) => resource.id === selectedResourceId) ?? null; @@ -665,6 +817,39 @@ export default function ProjectDevelopmentView({ approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const scheduleResourceDragPreview = useCallback( + (preview: Point & { resourceId: string }) => { + pendingResourceDragPreviewRef.current = preview; + if (resourceDragFrameRef.current !== null) { + return; + } + resourceDragFrameRef.current = window.requestAnimationFrame(() => { + resourceDragFrameRef.current = null; + const pending = pendingResourceDragPreviewRef.current; + pendingResourceDragPreviewRef.current = null; + if (pending) { + setResourceDragPreview(pending); + } + }); + }, + [], + ); + + const clearScheduledResourceDragPreview = useCallback(() => { + pendingResourceDragPreviewRef.current = null; + if (resourceDragFrameRef.current !== null) { + window.cancelAnimationFrame(resourceDragFrameRef.current); + resourceDragFrameRef.current = null; + } + }, []); + + useEffect( + () => () => { + clearScheduledResourceDragPreview(); + }, + [clearScheduledResourceDragPreview], + ); + useEffect(() => { if (embeddedPreviewUrl) { setMode('run'); @@ -674,9 +859,10 @@ export default function ProjectDevelopmentView({ useEffect(() => { setSelectedResourceId(null); resourceCardDragRef.current = null; + clearScheduledResourceDragPreview(); setDraggedResourceId(null); setResourceDragPreview(null); - }, [projectPath]); + }, [clearScheduledResourceDragPreview, projectPath]); useEffect(() => { if (!selectedResourceId) { @@ -812,6 +998,7 @@ export default function ProjectDevelopmentView({ if (!position || position.section !== resource.category) { return; } + clearScheduledResourceDragPreview(); resourceCardDragRef.current = { pointerId: event.pointerId, resourceId: resource.id, @@ -842,7 +1029,7 @@ export default function ProjectDevelopmentView({ } drag.moved = true; setDraggedResourceId(drag.resourceId); - setResourceDragPreview({ + scheduleResourceDragPreview({ resourceId: drag.resourceId, x: Math.max(0, drag.startX + deltaX), y: Math.max(0, drag.startY + deltaY), @@ -858,6 +1045,7 @@ export default function ProjectDevelopmentView({ if (!drag || drag.pointerId !== event.pointerId) { return; } + clearScheduledResourceDragPreview(); resourceCardDragRef.current = null; if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture?.(event.pointerId); @@ -1050,29 +1238,49 @@ export default function ProjectDevelopmentView({ ) : 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, - } - : position, + const categoryResources = + visibleResourcesByCategory.get(category) ?? []; + const baseExtent = resourceBaseExtentByCategory.get( + category, + ) ?? { width: 0, height: 0 }; + const dragPosition = resourceDragPreview + ? resourcePositionById.get( + resourceDragPreview.resourceId, + ) + : undefined; + const previewInCategory = + dragPosition?.section === category + ? resourceDragPreview + : null; + const extent = { + width: + Math.max( + baseExtent.width, + previewInCategory + ? previewInCategory.x + + RESOURCE_CANVAS_CARD_WIDTH + + RESOURCE_CANVAS_COLUMN_GAP + : 0, + ) + + (sortMode === 'dependency' + ? RESOURCE_DEPENDENCY_VISUAL_GUTTER + : 0), + height: Math.max( + baseExtent.height, + previewInCategory + ? previewInCategory.y + + RESOURCE_CANVAS_CARD_HEIGHT + + RESOURCE_CANVAS_ROW_GAP + : 0, ), - ); + }; const Icon = categoryIcons[category]; return (
; + downstreamReferenceResourceIds: ReadonlySet; + referenceEdgeIds: ReadonlySet; + taskFlowIds: ReadonlySet; +}; + export type ProjectResourceGraph = { resourceIds: ReadonlySet; referenceEdges: ProjectResourceReferenceEdge[]; + referenceEdgeById: ReadonlyMap; taskFlows: ProjectResourceTaskFlow[]; + taskFlowById: ReadonlyMap; + connectionIndex: ReadonlyMap; + producerTaskIdByResourceId: ReadonlyMap; unresolvedReferenceResourceIds: string[]; cyclicResourceIds: ReadonlySet; cyclicTaskIds: ReadonlySet; + producerMappingTruncated: boolean; }; export type ProjectResourceGraphNeighbors = { @@ -43,18 +74,18 @@ export type ProjectResourceGraphNeighbors = { connectedEdgeIds: ReadonlySet; }; -type DirectedEdge = { - id: string; - sourceId: string; - targetId: string; -}; - -type CycleAnalysis = { - cyclicNodeIds: Set; - cyclicEdgeIds: Set; -}; - const emptyStringSet: ReadonlySet = new Set(); +const emptyStringMap: ReadonlyMap = new Map(); +const emptyConnectionMap: ReadonlyMap = + new Map(); +const emptyTaskFlowMap: ReadonlyMap = new Map< + string, + ProjectResourceTaskFlow +>(); +const emptyReferenceEdgeMap: ReadonlyMap< + string, + ProjectResourceReferenceEdge +> = new Map(); export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors = { @@ -63,13 +94,19 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbo connectedEdgeIds: emptyStringSet, }; -function stableEdgeId( - kind: ProjectResourceReferenceEdge['kind'] | ProjectResourceTaskFlow['kind'], - sourceId: string, - targetId: string, -) { - return `${kind}:${JSON.stringify([sourceId, targetId])}`; -} +export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = { + resourceIds: emptyStringSet, + referenceEdges: [], + referenceEdgeById: emptyReferenceEdgeMap, + taskFlows: [], + taskFlowById: emptyTaskFlowMap, + connectionIndex: emptyConnectionMap, + producerTaskIdByResourceId: emptyStringMap, + unresolvedReferenceResourceIds: [], + cyclicResourceIds: emptyStringSet, + cyclicTaskIds: emptyStringSet, + producerMappingTruncated: false, +}; function uniqueSorted(values: Iterable) { return Array.from(new Set(values)).sort((left, right) => @@ -77,231 +114,92 @@ function uniqueSorted(values: Iterable) { ); } -function analyzeDirectedCycles( - nodeIds: Iterable, - 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(); - const reverseAdjacency = new Map(); - 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(); - 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(); - 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(); - const cyclicEdgeIds = new Set(); - 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[], +export function normalizeProjectResourceGraph( + readModel: ProjectResourceGraphReadModel, ): ProjectResourceGraph { - const resourceById = new Map( - resources.map((resource) => [resource.id, resource]), - ); - const resourcesByTask = new Map(); - const resourcesByExternalId = new Map(); - 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(); - const referenceEdgeById = new Map(); - 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 resourceIds = new Set(uniqueSorted(readModel.resourceIds)); + const referenceEdges = readModel.referenceEdges + .filter( + (edge) => + edge.kind === 'asset-reference' && + resourceIds.has(edge.sourceResourceId) && + resourceIds.has(edge.targetResourceId), + ) + .sort((left, right) => left.id.localeCompare(right.id)); + const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id)); + const taskFlows = readModel.taskFlows + .flatMap((flow) => { + if (flow.kind !== 'task-flow') { + return []; } - 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(); - for (const targetTask of tasks) { - const targetResourceIds = resourcesByTask.get(targetTask.id) ?? []; - if (targetResourceIds.length === 0) { + const sourceResourceIds = uniqueSorted( + flow.sourceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ); + const targetResourceIds = uniqueSorted( + flow.targetResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ); + return sourceResourceIds.length > 0 && targetResourceIds.length > 0 + ? [{ ...flow, sourceResourceIds, targetResourceIds }] + : []; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + const taskFlowIds = new Set(taskFlows.map((flow) => flow.id)); + const connectionIndex = new Map(); + for (const index of readModel.connectionIndex) { + if (!resourceIds.has(index.resourceId)) { 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, - }); - } + connectionIndex.set(index.resourceId, { + upstreamReferenceResourceIds: new Set( + index.upstreamReferenceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + downstreamReferenceResourceIds: new Set( + index.downstreamReferenceResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + referenceEdgeIds: new Set( + index.referenceEdgeIds.filter((edgeId) => + referenceEdgeIds.has(edgeId), + ), + ), + taskFlowIds: new Set( + index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)), + ), + }); } - const taskFlows = Array.from(taskFlowById.values()).sort((left, right) => - left.id.localeCompare(right.id), + const producerTaskIdByResourceId = new Map( + readModel.producerAssignments.flatMap((assignment) => + resourceIds.has(assignment.resourceId) && assignment.taskId + ? [[assignment.resourceId, assignment.taskId] as const] + : [], + ), ); - for (const flow of taskFlows) { - flow.cyclic = taskCycles.cyclicEdgeIds.has(flow.id); - } return { - resourceIds: new Set(resourceById.keys()), + resourceIds, referenceEdges, + referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])), taskFlows, + taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])), + connectionIndex, + producerTaskIdByResourceId, unresolvedReferenceResourceIds: uniqueSorted( - unresolvedReferenceResourceIds, + readModel.unresolvedReferenceResourceIds, ), - cyclicResourceIds: referenceCycles.cyclicNodeIds, - cyclicTaskIds: taskCycles.cyclicNodeIds, + cyclicResourceIds: new Set( + readModel.cyclicResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + cyclicTaskIds: new Set(readModel.cyclicTaskIds), + producerMappingTruncated: Boolean(readModel.producerMappingTruncated), }; } @@ -312,21 +210,22 @@ export function projectResourceGraphNeighbors( if (!resourceId || !graph.resourceIds.has(resourceId)) { return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; } - const upstreamResourceIds = new Set(); - const downstreamResourceIds = new Set(); - const connectedEdgeIds = new Set(); - - 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); - } + const index = graph.connectionIndex.get(resourceId); + if (!index) { + return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; } - for (const flow of graph.taskFlows) { + const upstreamResourceIds = new Set( + index.upstreamReferenceResourceIds, + ); + const downstreamResourceIds = new Set( + index.downstreamReferenceResourceIds, + ); + const connectedEdgeIds = new Set(index.referenceEdgeIds); + for (const flowId of index.taskFlowIds) { + const flow = graph.taskFlowById.get(flowId); + if (!flow) { + continue; + } if (flow.targetResourceIds.includes(resourceId)) { flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id)); connectedEdgeIds.add(flow.id); diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index e5b508d09..da4df334a 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -5,8 +5,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; import { - createProjectResourceGraph, + normalizeProjectResourceGraph, type ProjectResourceGraph, + type ProjectResourceGraphReadModel, } from '../src/view/project-development/resourceDependencyGraphModel'; import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay'; @@ -25,45 +26,76 @@ function position( } function graphFixture() { - return createProjectResourceGraph( - [ + const referenceId = 'asset-reference:["source:one","target:one"]'; + const selfReferenceId = 'asset-reference:["unrelated","unrelated"]'; + const flowId = 'task-flow:["source-task","target-task"]'; + const resourceIds = [ + 'source:one', + 'source:two', + 'source:three', + 'target:one', + 'target:two', + 'target:three', + 'unrelated', + ]; + const readModel: ProjectResourceGraphReadModel = { + resourceIds, + referenceEdges: [ { - id: 'source:one', - producerTaskId: 'source-task', - externalResourceId: 'external-source', - referenceResourceIds: [], + id: referenceId, + kind: 'asset-reference', + sourceResourceId: 'source:one', + targetResourceId: 'target:one', + cyclic: false, }, - ...['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: selfReferenceId, + kind: 'asset-reference', + sourceResourceId: 'unrelated', + targetResourceId: 'unrelated', + cyclic: true, }, ], - [ - { id: 'source-task', dependencies: [] }, - { id: 'target-task', dependencies: ['source-task'] }, - { id: 'unrelated-task', dependencies: [] }, + taskFlows: [ + { + id: flowId, + kind: 'task-flow', + sourceTaskId: 'source-task', + targetTaskId: 'target-task', + sourceResourceIds: ['source:one', 'source:two', 'source:three'], + targetResourceIds: ['target:one', 'target:two', 'target:three'], + cyclic: false, + }, ], - ); + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: + resourceId === 'target:one' + ? ['source:one'] + : resourceId === 'unrelated' + ? ['unrelated'] + : [], + downstreamReferenceResourceIds: + resourceId === 'source:one' + ? ['target:one'] + : resourceId === 'unrelated' + ? ['unrelated'] + : [], + referenceEdgeIds: + resourceId === 'source:one' || resourceId === 'target:one' + ? [referenceId] + : resourceId === 'unrelated' + ? [selfReferenceId] + : [], + taskFlowIds: resourceId === 'unrelated' ? [] : [flowId], + })), + producerAssignments: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: ['unrelated'], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + return normalizeProjectResourceGraph(readModel); } function OverlayHarness({ @@ -71,11 +103,13 @@ function OverlayHarness({ positions, visibleResourceIds, selectedResourceId = null, + dragPreview = null, }: { graph: ProjectResourceGraph; positions: ProjectResourceCanvasPosition[]; visibleResourceIds: ReadonlySet; selectedResourceId?: string | null; + dragPreview?: { resourceId: string; x: number; y: number } | null; }) { return React.createElement( 'div', @@ -84,6 +118,7 @@ function OverlayHarness({ React.createElement(ResourceDependencyOverlay, { graph, positions, + dragPreview, visibleResourceIds, selectedResourceId, }), @@ -95,12 +130,14 @@ function overlayView( positions: ProjectResourceCanvasPosition[], visibleResourceIds: ReadonlySet, selectedResourceId: string | null = null, + dragPreview: { resourceId: string; x: number; y: number } | null = null, ) { return React.createElement(OverlayHarness, { graph, positions, visibleResourceIds, selectedResourceId, + dragPreview, }); } @@ -159,8 +196,10 @@ describe('ResourceDependencyOverlay', () => { view.rerender( overlayView( graph, - [position('source:one', 80, 40), position('target:one', 240, 0)], + positions, new Set(['source:one', 'target:one']), + null, + { resourceId: 'source:one', x: 80, y: 40 }, ), ); await waitFor(() => @@ -179,6 +218,56 @@ describe('ResourceDependencyOverlay', () => { ); }); + it('keeps the section origin when updating a dragged path', async () => { + const getBoundingClientRect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function () { + const isPlane = this.hasAttribute('data-resource-section-plane'); + return { + x: isPlane ? 160 : 20, + y: isPlane ? 90 : 10, + left: isPlane ? 160 : 20, + top: isPlane ? 90 : 10, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }; + }); + try { + const graph = graphFixture(); + const positions = [ + position('source:one', 0, 0), + position('target:one', 240, 0), + ]; + const visible = new Set(['source:one', 'target:one']); + const view = render(overlayView(graph, positions, visible)); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const selector = '[data-edge-kind="asset-reference"]'; + await waitFor(() => + expect(overlay.querySelector(selector)?.getAttribute('d')).toContain( + 'M 320 126', + ), + ); + + view.rerender( + overlayView(graph, positions, visible, null, { + resourceId: 'source:one', + x: 80, + y: 40, + }), + ); + await waitFor(() => + expect(overlay.querySelector(selector)?.getAttribute('d')).toContain( + 'M 400 166', + ), + ); + } finally { + getBoundingClientRect.mockRestore(); + } + }); + it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => { const graph = graphFixture(); const view = render( @@ -244,6 +333,76 @@ describe('ResourceDependencyOverlay', () => { expect(unrelated?.classList.contains('is-dimmed')).toBe(true); }); + it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => { + const resourceIds = Array.from( + { length: 4096 }, + (_, index) => `resource:${index}`, + ); + const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({ + id: `flow:${index}:${index + 1}`, + kind: 'task-flow' as const, + sourceTaskId: `task:${index}`, + targetTaskId: `task:${index + 1}`, + sourceResourceIds: [`resource:${index}`], + targetResourceIds: [resourceId], + cyclic: false, + })); + const graph = normalizeProjectResourceGraph({ + resourceIds, + referenceEdges: [], + taskFlows, + connectionIndex: resourceIds.map((resourceId, index) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [ + ...(index > 0 ? [`flow:${index - 1}:${index}`] : []), + ...(index < resourceIds.length - 1 + ? [`flow:${index}:${index + 1}`] + : []), + ], + })), + producerAssignments: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }); + const positions = [ + position('resource:2047', 0, 0), + position('resource:2048', 220, 0), + position('resource:2049', 440, 0), + ]; + const visible = new Set(positions.map(({ resourceId }) => resourceId)); + const view = render(overlayView(graph, positions, visible)); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + await waitFor(() => + expect( + overlay.querySelectorAll('[data-edge-kind="task-flow"]'), + ).toHaveLength(2), + ); + const setAttribute = vi.spyOn( + SVGElement.prototype, + 'setAttribute', + ); + try { + view.rerender( + overlayView(graph, positions, visible, null, { + resourceId: 'resource:2048', + x: 260, + y: 32, + }), + ); + const geometryUpdates = setAttribute.mock.calls.filter( + ([name]) => name === 'd', + ); + expect(geometryUpdates).toHaveLength(6); + } finally { + setAttribute.mockRestore(); + } + }); + it('disconnects layout observers when the SVG layer is destroyed', () => { const observe = vi.fn(); const disconnect = vi.fn(); @@ -257,16 +416,27 @@ describe('ResourceDependencyOverlay', () => { vi.stubGlobal('ResizeObserver', TestResizeObserver); try { const graph = graphFixture(); + const positions = Array.from(graph.resourceIds).map( + (resourceId, index) => position(resourceId, index * 200, 0), + ); const view = render( overlayView( graph, - Array.from(graph.resourceIds).map((resourceId, index) => - position(resourceId, index * 200, 0), - ), + positions, new Set(graph.resourceIds), ), ); + expect(observe).toHaveBeenCalledTimes(2); + view.rerender( + overlayView( + graph, + positions, + new Set(graph.resourceIds), + null, + { resourceId: 'source:one', x: 32, y: 24 }, + ), + ); expect(observe).toHaveBeenCalledTimes(2); view.unmount(); expect(disconnect).toHaveBeenCalledTimes(1); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index d1e10a9a9..449123398 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -732,7 +732,7 @@ export function registerProjectWorkbenchFoundationTests() { localPath: 'assets/spec-source.json', source: { kind: 'canvas', - taskId: 'art-director', + taskId: 'task-1', resourceId: 'canvas-spec-source', }, }, @@ -743,7 +743,7 @@ export function registerProjectWorkbenchFoundationTests() { localPath: 'assets/ui-dependency.json', source: { kind: 'canvas', - taskId: 'design-foundation', + taskId: 'task-2', resourceId: 'canvas-ui-target', referenceResourceIds: ['canvas-spec-source'], }, @@ -761,6 +761,130 @@ export function registerProjectWorkbenchFoundationTests() { }, ); + const referenceId = + 'asset-reference:["asset:dependency-spec","asset:dependency-ui"]'; + const selfReferenceId = + 'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]'; + const flowId = + 'task-flow:["art-director","design-foundation"]'; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + expect(args?.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:dependency-spec', + manifestAssetId: 'dependency-spec', + producerTaskId: null, + }), + ]), + ); + return { + resourceIds: [ + 'asset:dependency-spec', + 'asset:dependency-ui', + 'asset:unrelated-cycle', + ], + referenceEdges: [ + { + id: referenceId, + kind: 'asset-reference', + sourceResourceId: 'asset:dependency-spec', + targetResourceId: 'asset:dependency-ui', + cyclic: false, + }, + { + id: selfReferenceId, + kind: 'asset-reference', + sourceResourceId: 'asset:unrelated-cycle', + targetResourceId: 'asset:unrelated-cycle', + cyclic: true, + }, + ], + taskFlows: [ + { + id: flowId, + kind: 'task-flow', + sourceTaskId: 'art-director', + targetTaskId: 'design-foundation', + sourceResourceIds: ['asset:dependency-spec'], + targetResourceIds: ['asset:dependency-ui'], + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'asset:dependency-spec', + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: ['asset:dependency-ui'], + referenceEdgeIds: [referenceId], + taskFlowIds: [flowId], + }, + { + resourceId: 'asset:dependency-ui', + upstreamReferenceResourceIds: ['asset:dependency-spec'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [referenceId], + taskFlowIds: [flowId], + }, + { + resourceId: 'asset:unrelated-cycle', + upstreamReferenceResourceIds: [ + 'asset:unrelated-cycle', + ], + downstreamReferenceResourceIds: [ + 'asset:unrelated-cycle', + ], + referenceEdgeIds: [selfReferenceId], + taskFlowIds: [], + }, + ], + producerAssignments: [ + { + resourceId: 'asset:dependency-spec', + taskId: 'art-director', + }, + { + resourceId: 'asset:dependency-ui', + taskId: 'design-foundation', + }, + ], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: ['asset:unrelated-cycle'], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'workbench-resource-graph', + mode: args?.mode, + 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: 'workbench-resource-graph', + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const view = render( React.createElement(ProjectDevelopmentView, { projectName: '资源依赖图测试', diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts index 18b526081..0566384ae 100644 --- a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -1,157 +1,156 @@ import { describe, expect, it } from 'vitest'; import { - createProjectResourceGraph, + normalizeProjectResourceGraph, projectResourceGraphNeighbors, - type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, } from '../src/view/project-development/resourceDependencyGraphModel'; -function resource( - id: string, - producerTaskId: string | null, - options: Partial = {}, -): ProjectResourceGraphNodeInput { +function readModel( + overrides: Partial = {}, +): ProjectResourceGraphReadModel { return { - id, - producerTaskId, - externalResourceId: null, - referenceResourceIds: [], - ...options, + resourceIds: [], + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + ...overrides, }; } 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'] }, - ], + it('normalizes the Rust read model and filters stale resource endpoints', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: ['asset:source', 'asset:target'], + referenceEdges: [ + { + id: 'valid-reference', + kind: 'asset-reference', + sourceResourceId: 'asset:source', + targetResourceId: 'asset:target', + cyclic: false, + }, + { + id: 'ghost-reference', + kind: 'asset-reference', + sourceResourceId: 'asset:deleted', + targetResourceId: 'asset:target', + cyclic: false, + }, + ], + taskFlows: [ + { + id: 'valid-flow', + kind: 'task-flow', + sourceTaskId: 'source-task', + targetTaskId: 'target-task', + sourceResourceIds: ['asset:source', 'asset:deleted'], + targetResourceIds: ['asset:target'], + cyclic: false, + }, + { + id: 'ghost-flow', + kind: 'task-flow', + sourceTaskId: 'deleted-task', + targetTaskId: 'target-task', + sourceResourceIds: ['asset:deleted'], + targetResourceIds: ['asset:target'], + cyclic: false, + }, + ], + cyclicResourceIds: ['asset:source', 'asset:deleted'], + }), ); - expect(graph.referenceEdges).toEqual([ + expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([ + 'valid-reference', + ]); + expect(graph.taskFlows).toEqual([ expect.objectContaining({ - kind: 'asset-reference', - sourceResourceId: 'asset:spec', - targetResourceId: 'asset:ui', - cyclic: false, + id: 'valid-flow', + sourceResourceIds: ['asset:source'], + targetResourceIds: ['asset:target'], }), ]); - expect(graph.unresolvedReferenceResourceIds).toEqual(['missing-resource']); + expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source'])); }); - 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'] }, - ], + it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'], + referenceEdges: [ + { + id: 'reference:a', + kind: 'asset-reference', + sourceResourceId: 'source:a', + targetResourceId: 'target:a', + cyclic: false, + }, + ], + taskFlows: [ + { + id: 'flow:a-b', + kind: 'task-flow', + sourceTaskId: 'task:a', + targetTaskId: 'task:b', + sourceResourceIds: ['source:a', 'source:b'], + targetResourceIds: ['target:a', 'target:b'], + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'target:a', + upstreamReferenceResourceIds: ['source:a'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: ['reference:a'], + taskFlowIds: ['flow:a-b'], + }, + ], + }), ); - 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'); + const neighbors = projectResourceGraphNeighbors(graph, 'target:a'); expect(neighbors.upstreamResourceIds).toEqual( - new Set(['task-a:one', 'task-a:two']), + new Set(['source:a', 'source:b']), ); expect(neighbors.downstreamResourceIds).toEqual(new Set()); - expect(neighbors.connectedEdgeIds.size).toBe(2); + expect(neighbors.connectedEdgeIds).toEqual( + new Set(['reference:a', 'flow:a-b']), + ); }); - it('keeps graph construction linear for the supported resource bound', () => { - const resources = Array.from({ length: 4096 }, (_, index) => - resource(`resource:${index}`, `task:${index}`), + it('keeps real producer assignments and audit truncation metadata', () => { + const graph = normalizeProjectResourceGraph( + 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' }, + ], + producerMappingTruncated: true, + }), ); - 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); + expect(graph.producerTaskIdByResourceId).toEqual( + new Map([ + ['asset:spec', 'art-director'], + ['asset:ui', 'design-foundation'], + ]), + ); + expect(graph.producerMappingTruncated).toBe(true); + expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({ + upstreamResourceIds: new Set(), + downstreamResourceIds: new Set(), + connectedEdgeIds: new Set(), + }); }); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index e00ab7567..ef4fa28e4 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可 ### 5.2 资源画布布局(P1) -实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式的前端资源依赖关系图层也已落地,但不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 +实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 ```ts type ProjectResourceCanvasLayout = { @@ -250,14 +250,16 @@ type UpdateProjectResourceCanvasLayoutResult = #### 5.2.5 资源依赖关系图层 - 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 -- 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG 的 path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。 +- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 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` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 +- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。 - 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。 - 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。 - 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 - 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。 - 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。 +- Pointer Move 必须按动画帧合并;基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 ### 5.3 资源类型与替换兼容性(P1) @@ -402,10 +404,11 @@ type ProjectAgentMudPointAttribution = { 4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。 5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。 6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。 +7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。 ## 8. 非目标 -- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供前端派生展示,不建立新的资源业务真相。 +- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。 - 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。 - 不修改 SpacetimeDB schema。 - 不开放普通用户 Agent.md/Skill。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 206355a44..693abc26c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,13 +16,15 @@ --- -## 2026-07-31 AI 游戏创作资源依赖图采用纯前端派生 SVG +## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 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`。 +- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引。 +- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份。 +- 生命周期与边界:Pointer Move 先用 `requestAnimationFrame` 合帧;基础 positions 与拖动预览分离,SVG 静态拓扑保持复用,每帧只按局部索引更新拖动资源关联的 reference edge 和 task flow。`ResizeObserver` 在单个图层生命周期只创建一次。type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、Observer 和窗口监听。SVG 统一 `pointer-events: none`;path、marker、图结构和 section 原点从不持久化。 +- 数据边界:本切片只新增 Tauri Rust 只读 read model,不修改 layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server、SpacetimeDB schema 或生成绑定,也不引入第三方图表库。dependency section 只在显示层额外预留 `64px` 右侧视觉 gutter,卡片坐标和持久化布局不变。 +- 影响范围:`apps/ai-game-creator-shell` 的 Tauri project read model/command、项目开发资源投影、依赖图 DTO、SVG overlay、样式与前后端测试,以及工作台 PRD 和客户端实施计划。 +- 验证方式:Rust 定向测试覆盖真实 producer 映射、拒绝复用外部 `taskId`、证据缺失、去重、无效 ID、完整任务环、4096 任务链与聚合复杂度;前端模型和 SVG 测试覆盖 DTO 防御过滤、局部上下游、可见资源自引用闭环、搜索、高亮、单帧局部 path 更新和稳定 Observer;AppSurface 覆盖生产数据形状、两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 --- diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe9792898..8a5d2f94d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## External Editor taskId 不能当作本地 manifest taskId + +- 现象:画布资产之间已有橙色精确引用线,但依赖任务之间没有灰色 task flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。 +- 原因:`GameCreationAppAssetSource.taskId` 保存的是 External Editor 生成任务身份,命名空间与本地 `.agent/manifest.json` 的 Agent/task 身份不同;前端用 `taskById.get(source.taskId)` 会让真实画布资产全部失去 producer。 +- 处理:资源依赖图的 Tauri Rust read model 从有界 `.agent/agent.db` 读取 `agent.runtime.canvas.asset_generate`,以 `assetId -> agentId` 映射 producer,并要求 `agentId` 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭该资产的 task flow,不回退 `source.taskId`。精确 `asset-reference` 仍只依赖 manifest 中外部 resourceId 的唯一匹配。 +- 验证: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`。 + ## Jenkins 异步备份不能用 nohup 脱离作业 - 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e45244831..028317ddd 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -364,18 +364,20 @@ 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 +### 资源依赖关系图层 V1.1 -2026-07-31 起,项目工作台在不修改 Rust、layout sidecar 和既有布局模型的前提下增加纯前端资源依赖图层: +2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB: -- `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 标记,不触发递归展开。 +- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。 +- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。 +- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`;External Editor 返回并保存在 `source.taskId` 的 `task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。 +- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。 +- 资源引用图和完整任务依赖图在 Rust 分别使用迭代式强连通分量分析。任务环检测不能依赖可视 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。 +- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar。 +- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。 +- 基础 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 边裁剪,不在首轮预先引入额外复杂度。 ## 分阶段实施