diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 81fae0bee..3d079a82f 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,8 +1,8 @@ import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; -import { resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -300,16 +300,100 @@ function stopChild(child, signal = 'SIGTERM') { } } -function isProcessGroupAlive(processGroupId, killImpl = process.kill) { +function parseLinuxProcessStat(value) { + const commandEnd = value.lastIndexOf(')'); + if (commandEnd < 0) { + return null; + } + const fields = value + .slice(commandEnd + 1) + .trim() + .split(/\s+/); + if (fields.length < 3 || fields[0].length !== 1) { + return null; + } + const processGroupId = Number(fields[2]); + if (!Number.isInteger(processGroupId)) { + return null; + } + return { state: fields[0], processGroupId }; +} + +function readLinuxProcessGroupRunning( + processGroupId, + { + procRoot = '/proc', + readdirImpl = readdirSync, + readFileImpl = readFileSync, + } = {}, +) { + let entries; + try { + entries = readdirImpl(procRoot, { withFileTypes: true }); + } catch { + return null; + } + + let inspectedProcess = false; + for (const entry of entries) { + const name = typeof entry === 'string' ? entry : entry.name; + if (!/^\d+$/.test(name)) { + continue; + } + if (typeof entry !== 'string' && !entry.isDirectory()) { + continue; + } + let stat; + try { + stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8'); + } catch (error) { + // 进程可能在枚举后立刻退出;继续检查同组的其它成员。 + if (error?.code === 'ENOENT' || error?.code === 'ESRCH') { + continue; + } + return null; + } + const parsed = parseLinuxProcessStat(stat); + if (!parsed) { + return null; + } + inspectedProcess = true; + if ( + parsed?.processGroupId === processGroupId && + !['Z', 'X', 'x'].includes(parsed.state) + ) { + return true; + } + } + return inspectedProcess ? false : null; +} + +function isProcessGroupRunning( + processGroupId, + { + platform = process.platform, + killImpl = process.kill, + readLinuxProcessGroup = readLinuxProcessGroupRunning, + } = {}, +) { if (!Number.isInteger(processGroupId)) { return false; } try { killImpl(-processGroupId, 0); - return true; } catch (error) { return error?.code !== 'ESRCH'; } + if (platform !== 'linux') { + return true; + } + try { + // Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为 + // 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。 + return readLinuxProcessGroup(processGroupId) ?? true; + } catch { + return true; + } } async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { @@ -415,7 +499,7 @@ async function terminateChildTree( stopChild(child, 'SIGTERM'); if ( await waitUntil( - () => !isProcessGroupAlive(processGroupId, killImpl), + () => !isProcessGroupRunning(processGroupId, { platform, killImpl }), gracefulTimeoutMs, ) ) { @@ -430,7 +514,7 @@ async function terminateChildTree( } } const stopped = await waitUntil( - () => !isProcessGroupAlive(processGroupId, killImpl), + () => !isProcessGroupRunning(processGroupId, { platform, killImpl }), forceTimeoutMs, ); return { stopped, forced: true }; @@ -600,8 +684,10 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, + isProcessGroupRunning, preflightExistingVite, readChildFailure, + readLinuxProcessGroupRunning, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 8c8c4f998..d2c62d7d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -1609,6 +1609,24 @@ mod tests { let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-dag-policy", "测试正式任务图等待") .expect("init manifest DAG policy project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve manifest DAG Supervisor session"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "测试正式任务图等待", + "manifest-dag-policy-run", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue trusted autonomous root task"); assert!(!autonomous_manifest_dag_in_progress_at(&root).expect("read pending DAG")); update_manifest_task_status_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 98dfec582..6981f58f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -937,14 +937,16 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { "missing visual output must fail the current logical task" ); - assert!(schedule_autonomous_game_build_ready_tasks_at( + let scheduled_after_failure = schedule_autonomous_game_build_ready_tasks_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, PARENT_RUN_ID, 1, ) - .expect("repeat scheduling after visual failure") - .is_empty()); + .expect("schedule other ready work after visual failure"); + assert!(scheduled_after_failure + .iter() + .all(|scheduled| scheduled.state.agent_id != CHILD_ID)); let records = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( &root, CHILD_ID, )) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index 385d4066b..672ea98f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -1,6 +1,74 @@ use super::*; +async fn run_after_pending_stack_boundary( + future: std::pin::Pin + Send + 'static>>, +) -> T +where + T: Send + 'static, +{ + // Debug builds give pending execution, the background main loop, and queue draining large + // poll frames. The boxed future keeps that large frame out of its caller before a joined child + // task gives it an independent poll boundary. JoinSet still aborts the child if its parent + // continuation is dropped. + let mut tasks = tokio::task::JoinSet::new(); + tasks.spawn(future); + match tasks + .join_next() + .await + .expect("pending continuation task must exist") + { + Ok(output) => output, + Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), + Err(error) => panic!("pending continuation task was cancelled: {error}"), + } +} + +async fn run_game_creator_agent_background_task_after_pending_stack_boundary( + root: PathBuf, + agent_id: String, + task: String, + runtime: AgentRuntimeState, + continuation: AgentRuntimeContinuationContext, +) -> AgentBackgroundTaskOutcome { + run_after_pending_stack_boundary(Box::pin(async move { + run_game_creator_agent_background_task_with_context( + root, + agent_id, + task, + runtime, + continuation, + ) + .await + })) + .await +} + +async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( + root: PathBuf, + agent_id: String, +) { + run_after_pending_stack_boundary(Box::pin(async move { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + })) + .await; +} + pub(crate) async fn continue_game_creator_agent_pending_tool_action( + root: PathBuf, + agent_id: String, + pending: AgentRuntimePendingToolAction, + runtime: AgentRuntimeState, +) { + run_after_pending_stack_boundary(Box::pin(async move { + continue_game_creator_agent_pending_tool_action_within_stack_boundary( + root, agent_id, pending, runtime, + ) + .await; + })) + .await; +} + +async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( root: PathBuf, agent_id: String, mut pending: AgentRuntimePendingToolAction, @@ -61,7 +129,7 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); continuation.context_stalled = false; continuation.applied_steer_cursor = batch.planned_steer_cursor; - let outcome = run_game_creator_agent_background_task_with_context( + let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( root.clone(), agent_id.clone(), pending.task.clone(), @@ -70,7 +138,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ) .await; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( + root, agent_id, + ) + .await; } return; } @@ -79,7 +150,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( if !has_persisted_terminal_observation && stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) + .await; return; } if let Err(error) = validate_agent_runtime_pending_context(&root, &runtime, &pending) { @@ -341,14 +413,18 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( &pending, &observation, ) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( + root, agent_id, + ) + .await; } return; } if observation.is_waiting_for_confirmation() && stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) + .await; return; } if observation.is_waiting_for_confirmation() { @@ -525,7 +601,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( }), ); if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) + .await; return; } if !auto_execution { @@ -702,7 +779,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( runtime, &format!("恢复 Agent Runtime context bundle 失败:{error}"), ); - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( + root, agent_id, + ) + .await; return; } }; @@ -753,7 +833,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( runtime, &format!("持久化 Agent Runtime context bundle 失败:{error}"), ); - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( + root, agent_id, + ) + .await; return; } }; @@ -787,7 +870,7 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( return; } } - let outcome = run_game_creator_agent_background_task_with_context( + let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( root.clone(), agent_id.clone(), pending.task.clone(), @@ -796,7 +879,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ) .await; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { - drain_next_game_creator_agent_background_tasks(root, agent_id).await; + drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) + .await; } } 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 5c9f77367..acee27f43 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 b27d185f0..61fd376e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2025,6 +2025,7 @@ mod game_chat_release_client_exit_tests { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2378,6 +2379,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..3cf87fe77 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,853 @@ +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, + pub dependency_depth: u32, +} + +#[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, + component_by_node: BTreeMap, +} + +#[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.component_by_node = component_by_node; + result +} + +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> 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_dependency_depths = dependency_depth_by_node(&task_cycles, &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, + dependency_depth: task_dependency_depths + .get(&task_id) + .copied() + .unwrap_or_default(), + 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_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + 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)); + assert_eq!( + graph + .producer_assignments + .iter() + .find(|assignment| assignment.resource_id == "resource:4095") + .map(|assignment| assignment.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index 641ce4acf..f2b2e0f69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -300,55 +300,36 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi let AgentRuntimeProviderActionBatchPreparation::Ready(valid_batch) = preparation else { panic!("valid autonomous responsibilities must form a ready durable batch"); }; - assert_eq!(valid_batch.actions.len(), 2); + assert_eq!(valid_batch.actions.len(), 3); assert!(valid_batch.collaboration_contract.is_some()); let valid_batch_id = valid_batch.batch_id.clone(); - let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ); - quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ - "直接写入 game/index.html 修复试玩问题", - "修改后执行静态验证并交付新 revision" - ]); + let mut design_not_read_only = valid_autonomous_initial_responsibility_actions_for_test(); + design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目并完成首轮策划实现。"); + design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]); + let mut design_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test(); + design_with_artifacts[0].input["expectedArtifacts"] = + serde_json::json!(["game/game_design.md"]); + let mut art_missing_spec = valid_autonomous_initial_responsibility_actions_for_test(); + art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); + let mut code_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test(); + code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]); let invalid_cases = vec![ ( - "code-missing-game-index", - "game/index.html", - autonomous_initial_responsibility_actions_for_test( - "创建可直接试玩的游戏实现并执行静态验证。", - &["game/main.js"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ), + "design-not-read-only", + "design-director", + design_not_read_only, ), ( - "code-read-only", - "code-prototype", - autonomous_initial_responsibility_actions_for_test( - "只读检查 game/index.html,不要修改任何项目文件。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ), - ), - ( - "quality-not-read-only", - "quality-review", - quality_not_read_only, - ), - ( - "quality-with-artifacts", + "design-with-artifacts", "expectedArtifacts", - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &["game/index.html"], - ), + design_with_artifacts, + ), + ("art-missing-spec", "assets/art-spec.png", art_missing_spec), + ( + "code-with-artifacts", + "expectedArtifacts", + code_with_artifacts, ), ]; for (case_name, expected_error, actions) in invalid_cases { @@ -389,12 +370,7 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi rewrite_autonomous_responsibility_batch_actions_for_test( &root, &mut legacy_v2_batch, - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ), + valid_autonomous_initial_responsibility_actions_for_test(), ); let legacy_v2_schema = "game-creator-provider-action-batch.v2"; legacy_v2_batch.schema_version = legacy_v2_schema.to_string(); @@ -600,68 +576,37 @@ async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mut fs::remove_dir_all(root).ok(); } -fn autonomous_initial_responsibility_actions_for_test( - code_task: &str, - code_artifacts: &[&str], - quality_task: &str, - quality_artifacts: &[&str], -) -> Vec { +fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec { vec![ - AgentRuntimeToolAction { - tool: "agent.delegate".to_string(), - reason: Some("委派程序 Agent 形成可玩入口".to_string()), - input: serde_json::json!({ - "agentId": "code-prototype", - "task": code_task, - "acceptanceCriteria": [ - "game/index.html 必须形成可直接试玩的完整入口", - "程序交付必须完成当前 revision 的静态验证" - ], - "expectedArtifacts": code_artifacts, - "repairOfDelegationId": null, - "runId": null - }), - }, - AgentRuntimeToolAction { - tool: "agent.delegate".to_string(), - reason: Some("委派质量 Agent 独立只读验收".to_string()), - input: serde_json::json!({ - "agentId": "quality-review", - "task": quality_task, - "acceptanceCriteria": [ - "只读核对可玩性、交互闭环和阻塞问题", - "返回可追溯的验收结论,不修改项目文件" - ], - "expectedArtifacts": quality_artifacts, - "repairOfDelegationId": null, - "runId": null - }), - }, + autonomous_initial_leader_responsibility_action_for_test("design-director", &[]), + autonomous_initial_leader_responsibility_action_for_test( + "art-director", + &["assets/art-spec.png"], + ), + autonomous_initial_leader_responsibility_action_for_test("code-director", &[]), ] } -fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec { - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性与交互闭环;不要修改任何项目文件。", - &[], - ) -} - -fn autonomous_art_director_responsibility_action_for_test( +fn autonomous_initial_leader_responsibility_action_for_test( + agent_id: &str, expected_artifacts: &[&str], ) -> AgentRuntimeToolAction { + let read_only = matches!(agent_id, "design-director" | "code-director"); AgentRuntimeToolAction { tool: "agent.delegate".to_string(), - reason: Some("委派美术总监生成统一视觉规范图".to_string()), + reason: Some("建立首批 Leader 专业规划".to_string()), input: serde_json::json!({ - "agentId": "art-director", - "task": "生成项目统一视觉规范图并写入项目资产目录。", - "acceptanceCriteria": [ - "使用画布生成接口产出后续 UI 与图集共用的规范图", - "生成结果必须登记为项目本地 icon-spec 资产" - ], + "agentId": agent_id, + "task": if read_only { + format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目") + } else { + "生成首轮统一视觉规范图供后续专业 Agent 使用".to_string() + }, + "acceptanceCriteria": if read_only { + serde_json::json!(["只读输出专业规划,不得修改项目文件"]) + } else { + serde_json::json!(["生成并登记统一视觉规范图"]) + }, "expectedArtifacts": expected_artifacts, "repairOfDelegationId": null, "runId": null @@ -713,9 +658,7 @@ async fn supervisor_autonomous_initial_art_director_requires_canonical_art_spec_ ) .expect("start autonomous Supervisor runtime"); let mut actions = valid_autonomous_initial_responsibility_actions_for_test(); - actions.push(autonomous_art_director_responsibility_action_for_test(&[ - "assets/art-preview.png", - ])); + actions[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); let plan = supervisor_collaboration_plan_for_test(actions); let revision = read_game_creator_agent_runtime_project_revision(&root) .expect("read art contract project revision"); @@ -834,40 +777,21 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef ) .expect("start autonomous Supervisor runtime"); - let code_missing_game_index = autonomous_initial_responsibility_actions_for_test( - "创建可直接试玩的游戏实现并执行静态验证。", - &["game/main.js"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ); - let code_read_only = autonomous_initial_responsibility_actions_for_test( - "只读检查 game/index.html,不要修改任何项目文件。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ); - let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ); - quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ - "直接写入 game/index.html 修复试玩问题", - "修改后执行静态验证并交付新 revision" - ]); - let quality_with_artifacts = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &["game/index.html"], - ); let valid = valid_autonomous_initial_responsibility_actions_for_test(); + let mut missing_design = valid.clone(); + missing_design.remove(0); + let mut design_not_read_only = valid.clone(); + design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目完成策划实现"); + design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]); + let mut art_missing_spec = valid.clone(); + art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); + let mut code_with_artifacts = valid.clone(); + code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]); let responses = [ - ("code-missing-game-index", &code_missing_game_index), - ("code-read-only", &code_read_only), - ("quality-not-read-only", &quality_not_read_only), - ("quality-with-artifacts", &quality_with_artifacts), + ("missing-design", &missing_design), + ("design-not-read-only", &design_not_read_only), + ("art-missing-spec", &art_missing_spec), + ("code-with-artifacts", &code_with_artifacts), ("valid-responsibilities", &valid), ] .into_iter() @@ -904,7 +828,13 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef .await .expect("repair invalid initial responsibilities") .expect("valid initial responsibility plan"); - assert_eq!(plan.actions, valid); + let mut expected_valid = valid.clone(); + expected_valid.sort_by(|left, right| { + left.input["agentId"] + .as_str() + .cmp(&right.input["agentId"].as_str()) + }); + assert_eq!(plan.actions, expected_valid); assert!(plan.response.is_empty()); let requests = (0..5) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 47ffc7c36..ff819cc52 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -5036,24 +5036,36 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .expect("project init"); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); - let code_arguments = serde_json::json!({ - "reason": "委派可玩原型实现", + let design_arguments = serde_json::json!({ + "reason": "委派策划 Leader", "input": { - "agentId": "code-prototype", - "task": "实现可直接试玩的游戏原型", - "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], - "expectedArtifacts": ["game/index.html"], + "agentId": "design-director", + "task": "只读拆解首轮玩法目标和专业分工,不得修改项目", + "acceptanceCriteria": ["只读输出策划规划,不得修改项目文件"], + "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null } }) .to_string(); - let quality_arguments = serde_json::json!({ - "reason": "委派独立质量评审", + let art_arguments = serde_json::json!({ + "reason": "委派美术 Leader", "input": { - "agentId": "quality-review", - "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", - "acceptanceCriteria": ["只读给出阻塞试玩的问题和验收结论"], + "agentId": "art-director", + "task": "生成首轮统一视觉规范图供后续专业 Agent 使用", + "acceptanceCriteria": ["生成并登记统一视觉规范图"], + "expectedArtifacts": ["assets/art-spec.png"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let code_arguments = serde_json::json!({ + "reason": "委派程序 Leader", + "input": { + "agentId": "code-director", + "task": "只读拆解首轮程序实现边界,不得修改项目", + "acceptanceCriteria": ["只读输出程序规划,不得修改项目文件"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null @@ -5061,16 +5073,21 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b }) .to_string(); let recovered_response = native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-autonomous-upstream-400-design", + delegate_function.as_str(), + design_arguments, + ), + ( + "call-autonomous-upstream-400-art", + delegate_function.as_str(), + art_arguments, + ), ( "call-autonomous-upstream-400-code", delegate_function.as_str(), code_arguments, ), - ( - "call-autonomous-upstream-400-quality", - delegate_function.as_str(), - quality_arguments, - ), ]); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); let base_url = spawn_mock_llm_upstream_400_then_raw_response( @@ -5156,7 +5173,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b assert!(request_notice_receiver .recv_timeout(Duration::from_millis(100)) .is_err()); - assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions.len(), 3); assert!(plan .actions .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index cd234fa8e..c138e990a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -707,7 +707,7 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { )); let scheduled = - schedule_game_creator_agent_ready_tasks_at(&root, 0).expect("schedule ready tasks"); + schedule_game_creator_agent_ready_tasks_at(&root, 1).expect("schedule one ready task"); assert_eq!(scheduled.len(), 1); assert_eq!(scheduled[0].state.agent_id, "design-director"); assert_eq!(scheduled[0].state.source, "agent-ready-task-scheduler"); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index cafeb314c..75f08c70f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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,96 @@ 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; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + transition: + opacity 140ms ease, + stroke-width 140ms ease; +} + +.game-resource-dependency-edge--reference { + stroke: #d96f3d; + stroke-width: 2.25px; + opacity: 0.94; +} + +.game-resource-dependency-edge--task path { + stroke: #918b87; + stroke-width: 1.4px; + stroke-dasharray: 4 7; +} + +.game-resource-dependency-edge--task .game-resource-dependency-trunk { + stroke-width: 1.7px; + opacity: 0.88; +} + +.game-resource-dependency-edge--task .game-resource-dependency-branch { + opacity: 0.72; +} + +.game-resource-dependency-edge.is-highlighted { + opacity: 1; +} + +.game-resource-dependency-edge--reference.is-highlighted { + stroke-width: 3px; +} + +.game-resource-dependency-edge--task.is-highlighted path { + opacity: 1; + stroke-width: 2px; +} + +.game-resource-dependency-edge--task.is-highlighted .game-resource-dependency-trunk { + stroke-width: 2.4px; +} + +.game-resource-dependency-edge.is-dimmed { + 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: #d96f3d; + stroke-linejoin: round; +} + +.game-resource-dependency-marker--task path { + fill: #918b87; + stroke-linejoin: round; +} + .game-resource-section { + position: relative; + z-index: 1; display: grid; gap: 10px; min-width: 620px; @@ -3978,6 +4065,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; 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 new file mode 100644 index 000000000..19ccd9bbb --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -0,0 +1,630 @@ +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + 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>; + +type RectLookup = { + get(resourceId: string): Rect | undefined; +}; + +export type ResourceDependencyOverlayProps = { + graph: ProjectResourceGraph; + positions: readonly ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; + selectedResourceId: string | null; +}; + +export type ResourceDependencyOverlayHandle = { + updateDragPreview: (preview: Point & { resourceId: string }) => void; + clearDragPreview: () => void; +}; + +type TaskFlowPathRefs = { + sourceBranches: Map; + targetBranches: Map; + trunk: SVGPathElement | null; +}; + +const SECTION_SELECTOR = '[data-resource-section-plane]'; +const TASK_FLOW_HUB_GAP = 20; +const CONNECTION_MAX_HANDLE = 180; +const TASK_FLOW_BRANCH_MAX_HANDLE = 96; +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.min( + CONNECTION_MAX_HANDLE, + Math.max( + 32, + Math.abs(target.x - source.x) * 0.42 + + Math.abs(target.y - source.y) * 0.08, + ), + ); + return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${ + source.y + }, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`; +} + +function taskFlowBranchPath(source: Point, target: Point) { + const horizontalDistance = Math.abs(target.x - source.x); + if (horizontalDistance < 1) { + const direction = target.y >= source.y ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + Math.abs(target.y - source.y) * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x} ${ + source.y + direction * handle + }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + } + const direction = target.x >= source.x ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + horizontalDistance * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ + source.y + }, ${target.x - direction * handle} ${target.y}, ${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: RectLookup, +) { + 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: RectLookup, +) { + 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 const ResourceDependencyOverlay = forwardRef< + ResourceDependencyOverlayHandle, + ResourceDependencyOverlayProps +>(function ResourceDependencyOverlay( + { graph, positions, visibleResourceIds, selectedResourceId }, + ref, +) { + const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); + const overlayRef = useRef(null); + const referencePathRefs = useRef(new Map()); + const taskFlowPathRefs = useRef(new Map()); + const activeDragPreviewRef = useRef< + (Point & { resourceId: string }) | null + >(null); + const graphRef = useRef(graph); + const positionByResourceIdRef = useRef( + new Map(positions.map((position) => [position.resourceId, position])), + ); + const rectByResourceIdRef = useRef>(new Map()); + const [sectionOrigins, setSectionOrigins] = useState({}); + + 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(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(SECTION_SELECTOR) + .forEach((plane) => observer?.observe(plane)); + window.addEventListener('resize', scheduleMeasure); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + observer?.disconnect(); + window.removeEventListener('resize', scheduleMeasure); + }; + }, []); + + const rectByResourceId = useMemo(() => { + const result = new Map(); + 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]); + graphRef.current = graph; + positionByResourceIdRef.current = new Map( + positions.map((position) => [position.resourceId, position]), + ); + rectByResourceIdRef.current = rectByResourceId; + + const neighbors = useMemo( + () => projectResourceGraphNeighbors(graph, selectedResourceId), + [graph, selectedResourceId], + ); + const selected = Boolean( + 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 ( + + {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ + flow.cyclic ? '(检测到依赖环)' : '' + }`} + {geometry.sourceAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + if (node) { + refs.sourceBranches.set(resourceId, node); + } else { + refs.sourceBranches.delete(resourceId); + } + }} + key={`source:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="source" + data-resource-id={resourceId} + d={taskFlowBranchPath(point, geometry.sourceHub)} + /> + ))} + { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + refs.trunk = node; + }} + className="game-resource-dependency-trunk" + d={connectionPath(geometry.sourceHub, geometry.targetHub)} + /> + {geometry.targetAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + if (node) { + refs.targetBranches.set(resourceId, node); + } else { + refs.targetBranches.delete(resourceId); + } + }} + key={`target:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="target" + data-resource-id={resourceId} + d={taskFlowBranchPath(geometry.targetHub, point)} + markerEnd={`url(#${markerPrefix}-task-flow-arrow)`} + /> + ))} + + ); + }), + [ + 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 activeFlowIds = new Set(graph.taskFlows.map((flow) => flow.id)); + for (const flowId of taskFlowPathRefs.current.keys()) { + if (!activeFlowIds.has(flowId)) { + taskFlowPathRefs.current.delete(flowId); + } + } + }, [graph.taskFlows]); + + const updateAffectedGeometry = useCallback( + ( + affectedResourceIds: ReadonlySet, + dragPreview: (Point & { resourceId: string }) | null, + ) => { + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; + const dragBasePosition = dragPreview + ? positionByResourceIdRef.current.get(dragPreview.resourceId) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = currentRects.get(resourceId); + if (!rect) { + return undefined; + } + return dragPreview?.resourceId === resourceId + ? { + ...rect, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = currentGraph.connectionIndex.get(resourceId); + index?.referenceEdgeIds.forEach((edgeId) => + affectedEdgeIds.add(edgeId), + ); + index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); + } + for (const edgeId of affectedEdgeIds) { + const referenceEdge = currentGraph.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 = currentGraph.taskFlowById.get(edgeId); + const paths = taskFlowPathRefs.current.get(edgeId); + if (!flow || !paths) { + continue; + } + const geometry = taskFlowGeometry(flow, rectLookup); + if (!geometry) { + continue; + } + geometry.sourceAnchors.forEach(({ resourceId, point }) => { + paths.sourceBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + paths.trunk?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); + geometry.targetAnchors.forEach(({ resourceId, point }) => { + paths.targetBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + const affectedResourceIds = new Set(); + if (activeDragPreviewRef.current) { + affectedResourceIds.add(activeDragPreviewRef.current.resourceId); + } + affectedResourceIds.add(preview.resourceId); + activeDragPreviewRef.current = preview; + updateAffectedGeometry(affectedResourceIds, preview); + }, + clearDragPreview() { + const active = activeDragPreviewRef.current; + activeDragPreviewRef.current = null; + if (active) { + updateAffectedGeometry(new Set([active.resourceId]), null); + } + }, + }), + [updateAffectedGeometry], + ); + + return ( + + ); +}); 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 dd5e99492..99870dfe7 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 @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { type CSSProperties, + memo, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, @@ -34,7 +35,6 @@ import type { GameCreationAppAgentGroup, GameCreationAppManifest, GameCreationAppPreviewState, - GameCreationAppTaskState, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -43,9 +43,25 @@ 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 { + EMPTY_PROJECT_RESOURCE_GRAPH, + normalizeProjectResourceGraph, + type ProjectResourceGraph, + projectResourceGraphNeighbors, + type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, +} from './resourceDependencyGraphModel'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from './ResourceDependencyOverlay'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; type AttachmentResult = { @@ -75,6 +91,11 @@ type ResourceCardDrag = { startX: number; startY: number; moved: boolean; + element: HTMLButtonElement; + plane: HTMLElement | null; + planeBaseWidth: number; + planeBaseHeight: number; + visualGutter: number; }; type ProjectResource = { @@ -86,6 +107,10 @@ type ProjectResource = { mediaType: string; sourceLabel: string; taskTitle: string | null; + manifestAssetId: string | null; + producerTaskId: string | null; + externalResourceId: string | null; + referenceResourceIds: string[]; dependencies: string[]; dependencyDepth: number; content?: string; @@ -131,6 +156,7 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; +const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; @@ -264,29 +290,6 @@ function imagePreviewErrorMessage(error: unknown) { return '图片暂时无法读取,请关闭后重试'; } -function taskDependencyDepth( - task: GameCreationAppTaskState, - taskById: Map, - seen = new Set(), -): number { - if (seen.has(task.id) || task.dependencies.length === 0) { - return 0; - } - const nextSeen = new Set(seen).add(task.id); - return ( - 1 + - Math.max( - 0, - ...task.dependencies.map((dependency) => { - const dependencyTask = taskById.get(dependency); - return dependencyTask - ? taskDependencyDepth(dependencyTask, taskById, nextSeen) - : 0; - }), - ) - ); -} - function resourcesFromProject( manifest: GameCreationAppManifest, attachments: AttachmentResult[], @@ -310,16 +313,17 @@ function resourcesFromProject( mediaType: category === 'document' ? '项目文档' : '项目产物', sourceLabel: '任务产物', taskTitle: task.title, + manifestAssetId: null, + producerTaskId: task.id, + externalResourceId: null, + referenceResourceIds: [], dependencies: task.dependencies, - dependencyDepth: taskDependencyDepth(task, taskById), + dependencyDepth: 0, }); } } 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'; @@ -340,9 +344,13 @@ function resourcesFromProject( : asset.source.kind === 'generated' ? 'Agent 生成' : '用户上传', - taskTitle: task?.title ?? null, - dependencies: task?.dependencies ?? [], - dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0, + taskTitle: null, + manifestAssetId: asset.id, + producerTaskId: null, + externalResourceId: asset.source.resourceId ?? null, + referenceResourceIds: asset.source.referenceResourceIds ?? [], + dependencies: [], + dependencyDepth: 0, }); } @@ -362,6 +370,10 @@ function resourcesFromProject( mediaType: attachment.mediaType || '未知媒体类型', sourceLabel: '用户上传', taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], dependencies: [], dependencyDepth: 0, }); @@ -377,6 +389,10 @@ function resourcesFromProject( mediaType: 'Agent 历史文本回执', sourceLabel: `历史成果 · ${result.label}`, taskTitle: null, + manifestAssetId: null, + producerTaskId: taskById.has(result.agentId) ? result.agentId : null, + externalResourceId: null, + referenceResourceIds: [], dependencies: [], dependencyDepth: 0, content: result.content, @@ -436,10 +452,11 @@ function summarizeAgent( }; } -function ResourceCard({ +const ResourceCard = memo(function ResourceCard({ resource, selected, dragging, + relationState, x, y, onSelect, @@ -451,10 +468,14 @@ function ResourceCard({ resource: ProjectResource; selected: boolean; dragging: boolean; + relationState: 'upstream' | 'downstream' | 'both' | null; x: number; y: number; - onSelect: () => void; - onPointerDown: (event: ReactPointerEvent) => void; + onSelect: (resourceId: string) => void; + onPointerDown: ( + event: ReactPointerEvent, + resource: ProjectResource, + ) => void; onPointerMove: (event: ReactPointerEvent) => void; onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; @@ -465,7 +486,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="拖动调整资源位置" @@ -475,8 +496,8 @@ function ResourceCard({ '--resource-y': `${y}px`, } as CSSProperties } - onClick={onSelect} - onPointerDown={onPointerDown} + onClick={() => onSelect(resource.id)} + onPointerDown={(event) => onPointerDown(event, resource)} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} @@ -489,7 +510,7 @@ function ResourceCard({ {resource.path} ); -} +}); export default function ProjectDevelopmentView({ projectName, @@ -516,9 +537,6 @@ export default function ProjectDevelopmentView({ const [draggedResourceId, setDraggedResourceId] = useState( null, ); - const [resourceDragPreview, setResourceDragPreview] = useState< - (Point & { resourceId: string }) | null - >(null); const [resourceDialogPosition, setResourceDialogPosition] = useState(null); const [imagePreview, setImagePreview] = useState({ @@ -530,6 +548,12 @@ export default function ProjectDevelopmentView({ const dockRef = useRef(null); const resourceDialogRef = useRef(null); const resourceCardDragRef = useRef(null); + const resourceDependencyOverlayRef = + useRef(null); + const pendingResourceDragPreviewRef = useRef< + (Point & { resourceId: string }) | null + >(null); + const resourceDragFrameRef = useRef(null); const suppressResourceClickRef = useRef(null); const resourceDialogDragRef = useRef<{ pointerId: number; @@ -544,10 +568,140 @@ 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; + status: 'idle' | 'loading' | 'ready' | 'failed'; + graph: ProjectResourceGraph; + }>({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + + useEffect(() => { + let cancelled = false; + if (sortMode !== 'dependency') { + setResourceGraphState({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'loading', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) + .then((readModel) => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'ready', + graph: normalizeProjectResourceGraph(readModel), + }); + } + }) + .catch(() => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + manifest.projectId, + projectPath, + resourceGraphInputs, + resourceGraphScopeKey, + sortMode, + ]); + + const resourceGraphScopeMatches = + resourceGraphState.scopeKey === resourceGraphScopeKey; + const resourceGraphReady = + resourceGraphScopeMatches && resourceGraphState.status === 'ready'; + const resourceGraph = + resourceGraphReady + ? resourceGraphState.graph + : EMPTY_PROJECT_RESOURCE_GRAPH; + const resourceGraphInitializationReady = + sortMode !== 'dependency' || + (resourceGraphScopeMatches && + (resourceGraphState.status === 'ready' || + resourceGraphState.status === 'failed')); + 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 { + ...resource, + taskTitle: producerTask?.title ?? resource.taskTitle, + producerTaskId, + dependencies: producerTask?.dependencies ?? resource.dependencies, + dependencyDepth: + resourceGraph.dependencyDepthByResourceId.get(resource.id) ?? 0, + }; + }), + [manifestTaskById, projectedResources, resourceGraph], + ); const { layout: resourceLayout, notice: resourceLayoutNotice, @@ -558,20 +712,78 @@ export default function ProjectDevelopmentView({ projectId: manifest.projectId, mode: sortMode, resources, + initializationReady: resourceGraphInitializationReady, + rederiveAutomaticPositions: + sortMode === 'dependency' && resourceGraphReady, }); - const resourcePositionById = new Map( - resourceLayout.positions.map((position) => [position.resourceId, position]), + const resourcePositionById = useMemo( + () => + new Map( + resourceLayout.positions.map((position) => [ + position.resourceId, + position, + ]), + ), + [resourceLayout.positions], + ); + 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 visibleResourcesByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + visibleResources.filter( + (resource) => resource.category === category, + ), + ]), + ), + [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; @@ -614,6 +826,68 @@ export default function ProjectDevelopmentView({ approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const applyResourceDragPreview = useCallback( + (preview: Point & { resourceId: string }) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.resourceId !== preview.resourceId) { + return; + } + drag.element.style.setProperty('--resource-x', `${preview.x}px`); + drag.element.style.setProperty('--resource-y', `${preview.y}px`); + if (drag.plane) { + drag.plane.style.width = `${Math.max( + drag.planeBaseWidth, + preview.x + + RESOURCE_CANVAS_CARD_WIDTH + + RESOURCE_CANVAS_COLUMN_GAP + + drag.visualGutter, + )}px`; + drag.plane.style.height = `${Math.max( + drag.planeBaseHeight, + preview.y + + RESOURCE_CANVAS_CARD_HEIGHT + + RESOURCE_CANVAS_ROW_GAP, + )}px`; + } + resourceDependencyOverlayRef.current?.updateDragPreview(preview); + }, + [], + ); + + 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) { + applyResourceDragPreview(pending); + } + }); + }, + [applyResourceDragPreview], + ); + + const clearScheduledResourceDragPreview = useCallback(() => { + pendingResourceDragPreviewRef.current = null; + if (resourceDragFrameRef.current !== null) { + window.cancelAnimationFrame(resourceDragFrameRef.current); + resourceDragFrameRef.current = null; + } + }, []); + + useEffect( + () => () => { + clearScheduledResourceDragPreview(); + resourceDependencyOverlayRef.current?.clearDragPreview(); + }, + [clearScheduledResourceDragPreview], + ); + useEffect(() => { if (embeddedPreviewUrl) { setMode('run'); @@ -623,9 +897,10 @@ export default function ProjectDevelopmentView({ useEffect(() => { setSelectedResourceId(null); resourceCardDragRef.current = null; + clearScheduledResourceDragPreview(); + resourceDependencyOverlayRef.current?.clearDragPreview(); setDraggedResourceId(null); - setResourceDragPreview(null); - }, [projectPath]); + }, [clearScheduledResourceDragPreview, projectPath]); useEffect(() => { if (!selectedResourceId) { @@ -750,79 +1025,135 @@ export default function ProjectDevelopmentView({ return () => window.removeEventListener('resize', clampOnResize); }, [clampResourceDialogPosition, selectedResource]); - function handleResourceCardPointerDown( - event: ReactPointerEvent, - resource: ProjectResource, - ) { - if (event.button !== 0) { + const handleResourceSelect = useCallback((resourceId: string) => { + if (suppressResourceClickRef.current === resourceId) { + suppressResourceClickRef.current = null; return; } - const position = resourcePositionById.get(resource.id); - if (!position || position.section !== resource.category) { - return; - } - resourceCardDragRef.current = { - pointerId: event.pointerId, - resourceId: resource.id, - section: resource.category, - startClientX: event.clientX, - startClientY: event.clientY, - startX: position.x, - startY: position.y, - moved: false, - }; - event.currentTarget.setPointerCapture?.(event.pointerId); - } + setSelectedResourceId(resourceId); + }, []); - function handleResourceCardPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startClientX; - const deltaY = event.clientY - drag.startClientY; - if ( - !drag.moved && - Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD - ) { - return; - } - drag.moved = true; - setDraggedResourceId(drag.resourceId); - setResourceDragPreview({ - resourceId: drag.resourceId, - x: Math.max(0, drag.startX + deltaX), - y: Math.max(0, drag.startY + deltaY), - }); - event.preventDefault(); - } + const handleResourceCardPointerDown = useCallback( + ( + event: ReactPointerEvent, + resource: ProjectResource, + ) => { + if (event.button !== 0) { + return; + } + const position = resourcePositionById.get(resource.id); + if (!position || position.section !== resource.category) { + return; + } + clearScheduledResourceDragPreview(); + const plane = event.currentTarget.parentElement; + resourceCardDragRef.current = { + pointerId: event.pointerId, + resourceId: resource.id, + section: resource.category, + startClientX: event.clientX, + startClientY: event.clientY, + startX: position.x, + startY: position.y, + moved: false, + element: event.currentTarget, + plane, + planeBaseWidth: + Number.parseFloat(plane?.style.width ?? '') || + plane?.getBoundingClientRect().width || + 0, + planeBaseHeight: + Number.parseFloat(plane?.style.height ?? '') || + plane?.getBoundingClientRect().height || + 0, + visualGutter: + sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [clearScheduledResourceDragPreview, resourcePositionById, sortMode], + ); - function handleResourceCardPointerEnd( - event: ReactPointerEvent, - cancelled: boolean, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - resourceCardDragRef.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - } - if (drag.moved && !cancelled) { - suppressResourceClickRef.current = drag.resourceId; - commitResourcePosition( - drag.resourceId, - drag.section, - Math.max(0, drag.startX + event.clientX - drag.startClientX), - Math.max(0, drag.startY + event.clientY - drag.startClientY), - ); - } - setDraggedResourceId(null); - setResourceDragPreview(null); - } + const handleResourceCardPointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - drag.startClientX; + const deltaY = event.clientY - drag.startClientY; + if ( + !drag.moved && + Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD + ) { + return; + } + if (!drag.moved) { + drag.moved = true; + setDraggedResourceId(drag.resourceId); + } + scheduleResourceDragPreview({ + resourceId: drag.resourceId, + x: Math.max(0, drag.startX + deltaX), + y: Math.max(0, drag.startY + deltaY), + }); + event.preventDefault(); + }, + [scheduleResourceDragPreview], + ); + + const handleResourceCardPointerEnd = useCallback( + (event: ReactPointerEvent, cancelled: boolean) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + clearScheduledResourceDragPreview(); + const finalPreview = { + resourceId: drag.resourceId, + x: cancelled + ? drag.startX + : Math.max(0, drag.startX + event.clientX - drag.startClientX), + y: cancelled + ? drag.startY + : Math.max(0, drag.startY + event.clientY - drag.startClientY), + }; + applyResourceDragPreview(finalPreview); + resourceCardDragRef.current = null; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + if (drag.moved && !cancelled) { + suppressResourceClickRef.current = drag.resourceId; + commitResourcePosition( + drag.resourceId, + drag.section, + finalPreview.x, + finalPreview.y, + ); + const overlay = resourceDependencyOverlayRef.current; + window.requestAnimationFrame(() => overlay?.clearDragPreview()); + } else { + resourceDependencyOverlayRef.current?.clearDragPreview(); + } + setDraggedResourceId(null); + }, + [ + applyResourceDragPreview, + clearScheduledResourceDragPreview, + commitResourcePosition, + ], + ); + const handleResourceCardPointerUp = useCallback( + (event: ReactPointerEvent) => + handleResourceCardPointerEnd(event, false), + [handleResourceCardPointerEnd], + ); + const handleResourceCardPointerCancel = useCallback( + (event: ReactPointerEvent) => + handleResourceCardPointerEnd(event, true), + [handleResourceCardPointerEnd], + ); function handleResourceDialogPointerDown( event: ReactPointerEvent, @@ -994,95 +1325,106 @@ 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 ( -
-
- - - {categoryResources.length} -
- {categoryResources.length > 0 ? ( -
- {categoryResources.map((resource) => { - const position = resourcePositionById.get( - resource.id, - ); - if (!position) { - return null; - } - const preview = - resourceDragPreview?.resourceId === resource.id - ? resourceDragPreview - : null; - return ( - { - if ( - suppressResourceClickRef.current === - resource.id - ) { - suppressResourceClickRef.current = null; - return; +
+ {sortMode === 'dependency' ? ( + + ) : null} + {categoryOrder.map((category) => { + const categoryResources = + visibleResourcesByCategory.get(category) ?? []; + const baseExtent = resourceBaseExtentByCategory.get( + category, + ) ?? { width: 0, height: 0 }; + const extent = { + width: + baseExtent.width + + (sortMode === 'dependency' + ? RESOURCE_DEPENDENCY_VISUAL_GUTTER + : 0), + height: baseExtent.height, + }; + const Icon = categoryIcons[category]; + return ( +
+
+ + + {categoryResources.length} +
+ {categoryResources.length > 0 ? ( +
+ {categoryResources.map((resource) => { + const position = resourcePositionById.get( + resource.id, + ); + if (!position) { + return 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 ( + - handleResourceCardPointerDown(event, resource) - } - onPointerMove={handleResourceCardPointerMove} - onPointerUp={(event) => - handleResourceCardPointerEnd(event, false) - } - onPointerCancel={(event) => - handleResourceCardPointerEnd(event, true) - } - /> - ); - })} -
- ) : ( -

暂无已登记资源

- )} -
- ); - })} + /> + ); + })} +
+ ) : ( +

暂无已登记资源

+ )} +
+ ); + })} + ) : ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts new file mode 100644 index 000000000..57d56662f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts @@ -0,0 +1,257 @@ +export type ProjectResourceGraphNodeInput = { + resourceId: string; + manifestAssetId: string | null; + producerTaskId: string | null; +}; + +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 ProjectResourceConnectionIndexDto = { + resourceId: string; + upstreamReferenceResourceIds: string[]; + downstreamReferenceResourceIds: string[]; + referenceEdgeIds: string[]; + taskFlowIds: string[]; +}; + +export type ProjectResourceProducerAssignment = { + resourceId: string; + taskId: string; + dependencyDepth: number; +}; + +export type ProjectResourceGraphReadModel = { + resourceIds: string[]; + referenceEdges: ProjectResourceReferenceEdge[]; + taskFlows: ProjectResourceTaskFlow[]; + connectionIndex: ProjectResourceConnectionIndexDto[]; + producerAssignments: ProjectResourceProducerAssignment[]; + unresolvedReferenceResourceIds: string[]; + cyclicResourceIds: string[]; + cyclicTaskIds: string[]; + producerMappingTruncated: boolean; +}; + +type ProjectResourceConnectionIndex = { + upstreamReferenceResourceIds: ReadonlySet; + downstreamReferenceResourceIds: ReadonlySet; + referenceEdgeIds: ReadonlySet; + taskFlowIds: ReadonlySet; +}; + +export type ProjectResourceGraph = { + resourceIds: ReadonlySet; + referenceEdges: ProjectResourceReferenceEdge[]; + referenceEdgeById: ReadonlyMap; + taskFlows: ProjectResourceTaskFlow[]; + taskFlowById: ReadonlyMap; + connectionIndex: ReadonlyMap; + producerTaskIdByResourceId: ReadonlyMap; + dependencyDepthByResourceId: ReadonlyMap; + unresolvedReferenceResourceIds: string[]; + cyclicResourceIds: ReadonlySet; + cyclicTaskIds: ReadonlySet; + producerMappingTruncated: boolean; +}; + +export type ProjectResourceGraphNeighbors = { + upstreamResourceIds: ReadonlySet; + downstreamResourceIds: ReadonlySet; + connectedEdgeIds: ReadonlySet; +}; + +const emptyStringSet: ReadonlySet = new Set(); +const emptyStringMap: ReadonlyMap = new Map(); +const emptyNumberMap: 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 = + { + upstreamResourceIds: emptyStringSet, + downstreamResourceIds: emptyStringSet, + connectedEdgeIds: emptyStringSet, + }; + +export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = { + resourceIds: emptyStringSet, + referenceEdges: [], + referenceEdgeById: emptyReferenceEdgeMap, + taskFlows: [], + taskFlowById: emptyTaskFlowMap, + connectionIndex: emptyConnectionMap, + producerTaskIdByResourceId: emptyStringMap, + dependencyDepthByResourceId: emptyNumberMap, + unresolvedReferenceResourceIds: [], + cyclicResourceIds: emptyStringSet, + cyclicTaskIds: emptyStringSet, + producerMappingTruncated: false, +}; + +function uniqueSorted(values: Iterable) { + return Array.from(new Set(values)).sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); +} + +export function normalizeProjectResourceGraph( + readModel: ProjectResourceGraphReadModel, +): ProjectResourceGraph { + 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 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; + } + 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 producerTaskIdByResourceId = new Map(); + const dependencyDepthByResourceId = new Map(); + for (const assignment of readModel.producerAssignments) { + if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) { + continue; + } + producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId); + if ( + Number.isSafeInteger(assignment.dependencyDepth) && + assignment.dependencyDepth >= 0 + ) { + dependencyDepthByResourceId.set( + assignment.resourceId, + assignment.dependencyDepth, + ); + } + } + + return { + resourceIds, + referenceEdges, + referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])), + taskFlows, + taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])), + connectionIndex, + producerTaskIdByResourceId, + dependencyDepthByResourceId, + unresolvedReferenceResourceIds: uniqueSorted( + readModel.unresolvedReferenceResourceIds, + ), + cyclicResourceIds: new Set( + readModel.cyclicResourceIds.filter((resourceId) => + resourceIds.has(resourceId), + ), + ), + cyclicTaskIds: new Set(readModel.cyclicTaskIds), + producerMappingTruncated: Boolean(readModel.producerMappingTruncated), + }; +} + +export function projectResourceGraphNeighbors( + graph: ProjectResourceGraph, + resourceId: string | null, +): ProjectResourceGraphNeighbors { + if (!resourceId || !graph.resourceIds.has(resourceId)) { + return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; + } + const index = graph.connectionIndex.get(resourceId); + if (!index) { + return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; + } + 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); + } + 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 }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 745ca2c37..746ee88b8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -81,16 +81,58 @@ function layoutMatchesScope( return layout.projectId === scope.projectId && layout.mode === scope.mode; } +function positionsEqual( + left: ProjectResourceCanvasLayout['positions'], + right: ProjectResourceCanvasLayout['positions'], +) { + return ( + left.length === right.length && + left.every((position, index) => { + const other = right[index]; + return ( + other?.resourceId === position.resourceId && + other.section === position.section && + other.x === position.x && + other.y === position.y && + other.manuallyPlaced === position.manuallyPlaced + ); + }) + ); +} + +function reconcileLayout( + source: ProjectResourceCanvasLayout, + resources: ResourceCanvasItem[], + rederiveAutomaticPositions: boolean, +) { + if (!rederiveAutomaticPositions) { + return reconcileResourceCanvasLayout(source, resources); + } + const manualSource = { + ...source, + positions: source.positions.filter((position) => position.manuallyPlaced), + }; + const reconciled = reconcileResourceCanvasLayout(manualSource, resources); + return { + layout: reconciled.layout, + changed: !positionsEqual(source.positions, reconciled.layout.positions), + }; +} + export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, resources, + initializationReady = true, + rederiveAutomaticPositions = false, }: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; + initializationReady?: boolean; + rederiveAutomaticPositions?: boolean; }) { const scopeKey = createScopeKey(projectPath, projectId, mode); const resourceSignature = useMemo( @@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({ [resources], ); const fallback = useMemo( - () => - reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), - resources, - ).layout, - [mode, projectId, resources], - ); + () => { + const empty = createEmptyResourceCanvasLayout(projectId, mode); + return initializationReady + ? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout + : empty; + }, [ + initializationReady, + mode, + projectId, + rederiveAutomaticPositions, + resources, + ]); const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); @@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({ if (scope.epoch !== scopeEpoch) { return; } - let next = reconcileResourceCanvasLayout( + let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).layout; for (const intent of writeQueueRef.current) { if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') { @@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({ } applyLayout(next); }, - [applyLayout], + [applyLayout, rederiveAutomaticPositions], ); enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { @@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({ return; } - const reconciled = reconcileResourceCanvasLayout( + const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); @@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({ setNotice('布局已保存'); } if ( - reconcileResourceCanvasLayout(result.layout, resourcesRef.current) + reconcileLayout( + result.layout, + resourcesRef.current, + rederiveAutomaticPositions, + ) .changed ) { enqueueResourceSyncRef.current(currentScope.epoch); @@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({ queued.scopeEpoch !== currentScope.epoch || queued.kind !== 'manual', ); - const needsResourceSync = reconcileResourceCanvasLayout( + const needsResourceSync = reconcileLayout( result.layout, resourcesRef.current, + rederiveAutomaticPositions, ).changed; const nextRetry = intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; @@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({ writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; - const initialFallback = reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), + const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); + if (!initializationReady) { + persistedLayoutRef.current = emptyLayout; + applyLayout(emptyLayout); + setNotice(''); + setSaving(false); + return undefined; + } + const initialFallback = reconcileLayout( + emptyLayout, resourcesRef.current, + rederiveAutomaticPositions, ).layout; persistedLayoutRef.current = initialFallback; applyLayout(initialFallback); @@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({ persistedLayoutRef.current = loaded; initializedScopeEpochRef.current = epoch; if ( - reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed + reconcileLayout( + loaded, + resourcesRef.current, + rederiveAutomaticPositions, + ).changed ) { enqueueResourceSyncRef.current(epoch); } @@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({ }; }, [ applyLayout, + initializationReady, mode, projectId, projectPath, rebuildOptimisticLayout, + rederiveAutomaticPositions, scopeKey, ]); @@ -502,25 +571,34 @@ export function useProjectResourceCanvasLayout({ const scope = scopeRef.current; if ( scope.key !== scopeKey || + !initializationReady || initializedScopeEpochRef.current !== scope.epoch ) { return; } - const reconciledCurrent = reconcileResourceCanvasLayout( + const reconciledCurrent = reconcileLayout( layoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); applyLayout(reconciledCurrent.layout); if ( window.__TAURI__?.core?.invoke && - reconcileResourceCanvasLayout( + reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).changed ) { enqueueResourceSyncRef.current(scope.epoch); } - }, [applyLayout, resourceSignature, scopeKey]); + }, [ + applyLayout, + initializationReady, + rederiveAutomaticPositions, + resourceSignature, + scopeKey, + ]); useEffect(() => { if (!notice) { @@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({ y: number, ) => { const scope = scopeRef.current; - if (scope.key !== scopeKey) { + if ( + scope.key !== scopeKey || + !initializationReady || + initializedScopeEpochRef.current !== scope.epoch + ) { return; } const queuedIntent = writeQueueRef.current.find( @@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({ setSaving(true); pumpWritesRef.current(); }, - [applyLayout, scopeKey], + [applyLayout, initializationReady, scopeKey], ); const scopeMatches = + initializationReady && scopeRef.current.key === scopeKey && layout.projectId === projectId && layout.mode === mode; diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts new file mode 100644 index 000000000..6348de9bf --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -0,0 +1,458 @@ +/** @vitest-environment jsdom */ +import { act, 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 { + normalizeProjectResourceGraph, + type ProjectResourceGraph, + type ProjectResourceGraphReadModel, +} from '../src/view/project-development/resourceDependencyGraphModel'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} 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() { + 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: referenceId, + kind: 'asset-reference', + sourceResourceId: 'source:one', + targetResourceId: 'target:one', + cyclic: false, + }, + { + id: selfReferenceId, + kind: 'asset-reference', + sourceResourceId: 'unrelated', + targetResourceId: 'unrelated', + cyclic: true, + }, + ], + 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({ + graph, + positions, + visibleResourceIds, + selectedResourceId = null, + overlayRef, +}: { + graph: ProjectResourceGraph; + positions: ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; + selectedResourceId?: string | null; + overlayRef?: React.Ref; +}) { + return React.createElement( + 'div', + null, + React.createElement('div', { 'data-resource-section-plane': 'art' }), + React.createElement(ResourceDependencyOverlay, { + ref: overlayRef, + graph, + positions, + visibleResourceIds, + selectedResourceId, + }), + ); +} + +function overlayView( + graph: ProjectResourceGraph, + positions: ProjectResourceCanvasPosition[], + visibleResourceIds: ReadonlySet, + selectedResourceId: string | null = null, + overlayRef?: React.Ref, +) { + return React.createElement(OverlayHarness, { + graph, + positions, + visibleResourceIds, + selectedResourceId, + overlayRef, + }); +} + +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(); + const taskPaths = Array.from( + taskFlow?.querySelectorAll('path') ?? [], + ); + expect(taskPaths).toHaveLength(7); + expect( + taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')), + ).toBe(true); + expect( + taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')), + ).toBe(false); + expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3); + expect( + overlay + .querySelector('marker[id$="-task-flow-arrow"]') + ?.getAttribute('markerUnits'), + ).toBe('userSpaceOnUse'); + }); + + 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 overlayRef = React.createRef(); + const view = render( + overlayView( + graph, + positions, + new Set(['source:one', 'target:one']), + null, + overlayRef, + ), + ); + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const firstPath = await waitFor(() => { + const path = overlay.querySelector( + '[data-edge-kind="asset-reference"]', + ); + expect(path).not.toBeNull(); + return path?.getAttribute('d'); + }); + + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 80, + y: 40, + }), + ); + 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('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 overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, null, overlayRef)); + 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', + ), + ); + + act(() => + overlayRef.current?.updateDragPreview({ + 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( + 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(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( + '[data-edge-kind="asset-reference"]', + ), + ).find((edge) => edge.getAttribute('data-cyclic') === 'true'); + 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 overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, null, overlayRef)); + 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 { + act(() => + overlayRef.current?.updateDragPreview({ + 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(); + class TestResizeObserver { + constructor(_callback: ResizeObserverCallback) {} + + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + try { + const graph = graphFixture(); + const positions = Array.from(graph.resourceIds).map( + (resourceId, index) => position(resourceId, index * 200, 0), + ); + const overlayRef = React.createRef(); + const view = render( + overlayView( + graph, + positions, + new Set(graph.resourceIds), + null, + overlayRef, + ), + ); + + expect(observe).toHaveBeenCalledTimes(2); + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 32, + y: 24, + }), + ); + expect(observe).toHaveBeenCalledTimes(2); + view.unmount(); + expect(disconnect).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 733c41033..96bee78ed 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -801,7 +801,9 @@ export function registerHomeProjectCreationTests() { expect(screen.getByLabelText('项目总控消息').textContent).toContain( '第一行\n第二行\n第三行', ); - expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull(); + expect( + await screen.findByText('assets/uploads/reference.png'), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/home-created-game', projectId: 'local-project-draft', 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 f204885be..3979b83e7 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 @@ -56,6 +56,40 @@ import { within, } from './harness'; +function resourceGraphForInputs(args?: Record) { + const resources = + (args?.resources as + | Array<{ resourceId: string; producerTaskId: string | null }> + | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: resources.flatMap((resource) => + resource.producerTaskId + ? [ + { + resourceId: resource.resourceId, + taskId: resource.producerTaskId, + dependencyDepth: 0, + }, + ] + : [], + ), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + function gameChatRuntimeEvent({ agentId = 'project-supervisor', taskId = agentId, @@ -746,6 +780,487 @@ 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: 'task-1', + resourceId: 'canvas-spec-source', + }, + }, + { + id: 'dependency-ui', + kind: 'ui-prototype', + mediaType: 'application/json', + localPath: 'assets/ui-dependency.json', + source: { + kind: 'canvas', + taskId: 'task-2', + 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 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', + dependencyDepth: 0, + }, + { + resourceId: 'asset:dependency-ui', + taskId: 'design-foundation', + dependencyDepth: 1, + }, + ], + 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: '资源依赖图测试', + 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('waits for the scoped resource graph before initializing dependency layout', async () => { + const projectId = 'workbench-delayed-resource-graph'; + const projectPath = '/tmp/workbench-delayed-resource-graph'; + const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试'); + const agentResults = [ + { + agentId: 'design-foundation', + runId: 'delayed-graph-run', + label: '玩法策划 Agent', + title: '延迟依赖图回执', + content: '图就绪后再初始化布局', + updatedAt: 1, + }, + ]; + let resolveGraph: (() => void) | null = null; + let layoutReads = 0; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return await new Promise((resolve) => { + resolveGraph = () => resolve(resourceGraphForInputs(args)); + }); + } + if (command === 'read_local_project_resource_canvas_layout') { + layoutReads += 1; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '延迟依赖图测试', + projectPath, + manifest, + attachments: [], + agentResults, + supervisor: React.createElement('div', null, '项目总控'), + }), + ); + + await waitFor(() => expect(resolveGraph).not.toBeNull()); + expect(layoutReads).toBe(0); + expect(screen.queryByText('延迟依赖图回执')).toBeNull(); + + await act(async () => { + resolveGraph?.(); + await Promise.resolve(); + }); + expect(await screen.findByText('延迟依赖图回执')).not.toBeNull(); + expect(layoutReads).toBe(1); + }); + + it( + 'keeps 4096 real resource cards out of React commits during preview frames', + async () => { + const projectId = 'workbench-resource-drag-performance'; + const projectPath = '/tmp/workbench-resource-drag-performance'; + const manifest = createGameCreationAppManifest( + projectId, + '资源拖动性能测试', + ); + const agentResults = Array.from({ length: 4096 }, (_, index) => ({ + agentId: 'design-foundation', + runId: `performance-run-${index}`, + label: `性能 Agent ${index}`, + title: `性能资源 ${index}`, + content: `性能正文 ${index}`, + updatedAt: index, + })); + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const commits: string[] = []; + + render( + React.createElement( + React.Profiler, + { + id: 'resource-workbench', + onRender: () => commits.push('commit'), + }, + React.createElement(ProjectDevelopmentView, { + projectName: '资源拖动性能测试', + projectPath, + manifest, + attachments: [], + agentResults, + supervisor: React.createElement('div', null, '项目总控'), + }), + ), + ); + + const target = (await screen.findByText('性能资源 2048')).closest( + 'button', + ); + expect(target).not.toBeNull(); + expect(document.querySelectorAll('.game-resource-card')).toHaveLength( + 4096, + ); + commits.length = 0; + let previewFrame: FrameRequestCallback | null = null; + const requestAnimationFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + previewFrame = callback; + return 77; + }); + const cancelAnimationFrame = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => undefined); + try { + fireEvent.pointerDown(target!, { + pointerId: 88, + button: 0, + clientX: 20, + clientY: 30, + }); + for (let index = 0; index < 100; index += 1) { + fireEvent.pointerMove(target!, { + pointerId: 88, + clientX: 40 + index, + clientY: 60 + index, + }); + } + act(() => previewFrame?.(16)); + + expect(commits).toHaveLength(1); + expect(target?.getAttribute('style')).toContain('--resource-x: 119px'); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + } finally { + fireEvent.pointerCancel(target!, { + pointerId: 88, + clientX: 139, + clientY: 159, + }); + requestAnimationFrame.mockRestore(); + cancelAnimationFrame.mockRestore(); + } + }, + 15_000, + ); + it('persists a resource position with CAS and restores it after remount', async () => { const projectId = 'workbench-layout-persistence'; const projectPath = '/tmp/workbench-layout-persistence'; @@ -779,6 +1294,9 @@ export function registerProjectWorkbenchFoundationTests() { }; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return structuredClone(persistedLayout); } @@ -818,7 +1336,7 @@ export function registerProjectWorkbenchFoundationTests() { } renderWorkbench(); - const card = screen.getByText('布局持久化回执').closest('button'); + const card = (await screen.findByText('布局持久化回执')).closest('button'); expect(card).not.toBeNull(); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 12px'); @@ -863,7 +1381,9 @@ export function registerProjectWorkbenchFoundationTests() { cleanup(); renderWorkbench(); - const restoredCard = screen.getByText('布局持久化回执').closest('button'); + const restoredCard = ( + await screen.findByText('布局持久化回执') + ).closest('button'); await waitFor(() => { expect(restoredCard?.getAttribute('style')).toContain( '--resource-x: 92px', @@ -895,7 +1415,11 @@ export function registerProjectWorkbenchFoundationTests() { ], updatedAt: 400, }; - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { ...structuredClone(latestLayout), @@ -913,7 +1437,8 @@ export function registerProjectWorkbenchFoundationTests() { return { status: 'conflict', layout: structuredClone(latestLayout) }; } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( @@ -935,7 +1460,7 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }), ); - const card = screen.getByText('布局冲突回执').closest('button'); + const card = (await screen.findByText('布局冲突回执')).closest('button'); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); }); @@ -1001,7 +1526,11 @@ export function registerProjectWorkbenchFoundationTests() { }) => void) | null = null; let updateCalls = 0; - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { ...structuredClone(latestLayout), @@ -1016,7 +1545,8 @@ export function registerProjectWorkbenchFoundationTests() { }); } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( @@ -1038,7 +1568,7 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }), ); - const card = screen.getByText('排队冲突回执').closest('button'); + const card = (await screen.findByText('排队冲突回执')).closest('button'); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); }); @@ -1112,6 +1642,9 @@ export function registerProjectWorkbenchFoundationTests() { }> = []; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } const mode = args?.mode as 'dependency' | 'type'; if (command === 'read_local_project_resource_canvas_layout') { return { @@ -1185,7 +1718,11 @@ export function registerProjectWorkbenchFoundationTests() { it('keeps newly reconciled resources visible when their automatic layout save fails', async () => { const projectId = 'workbench-layout-save-failure'; const manifest = createGameCreationAppManifest(projectId, '布局失败测试'); - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', @@ -1200,7 +1737,8 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error('disk full'); } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts new file mode 100644 index 000000000..0ed293914 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeProjectResourceGraph, + projectResourceGraphNeighbors, + type ProjectResourceGraphReadModel, +} from '../src/view/project-development/resourceDependencyGraphModel'; + +function readModel( + overrides: Partial = {}, +): ProjectResourceGraphReadModel { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + ...overrides, + }; +} + +describe('resource dependency graph model', () => { + 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.map((edge) => edge.id)).toEqual([ + 'valid-reference', + ]); + expect(graph.taskFlows).toEqual([ + expect.objectContaining({ + id: 'valid-flow', + sourceResourceIds: ['asset:source'], + targetResourceIds: ['asset:target'], + }), + ]); + expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source'])); + }); + + 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'], + }, + ], + }), + ); + + const neighbors = projectResourceGraphNeighbors(graph, 'target:a'); + expect(neighbors.upstreamResourceIds).toEqual( + new Set(['source:a', 'source:b']), + ); + expect(neighbors.downstreamResourceIds).toEqual(new Set()); + expect(neighbors.connectedEdgeIds).toEqual( + new Set(['reference:a', 'flow:a-b']), + ); + }); + + 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', + dependencyDepth: 0, + }, + { + resourceId: 'asset:ui', + taskId: 'design-foundation', + dependencyDepth: 1, + }, + { + resourceId: 'asset:deleted', + taskId: 'task-1', + dependencyDepth: 99, + }, + ], + producerMappingTruncated: true, + }), + ); + + expect(graph.producerTaskIdByResourceId).toEqual( + new Map([ + ['asset:spec', 'art-director'], + ['asset:ui', 'design-foundation'], + ]), + ); + expect(graph.dependencyDepthByResourceId).toEqual( + new Map([ + ['asset:spec', 0], + ['asset:ui', 1], + ]), + ); + expect(graph.producerMappingTruncated).toBe(true); + expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({ + upstreamResourceIds: new Set(), + downstreamResourceIds: new Set(), + connectedEdgeIds: new Set(), + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 50ea09925..c80615e14 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + isProcessGroupRunning, preflightExistingVite, + readLinuxProcessGroupRunning, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, @@ -154,6 +156,40 @@ describe('AI 游戏创作启动子进程生命周期', () => { } }); + test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => { + const stats = new Map([ + ['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'], + ['/proc/7001/stat', '7001 (other) S 1 7001 7001'], + ]); + const readLinuxGroup = () => + readLinuxProcessGroupRunning(4821, { + readdirImpl: () => [ + { name: '4822', isDirectory: () => true }, + { name: '7001', isDirectory: () => true }, + ], + readFileImpl: (path) => stats.get(path), + }); + + expect(readLinuxGroup()).toBe(false); + expect( + isProcessGroupRunning(4821, { + platform: 'linux', + killImpl: vi.fn(), + readLinuxProcessGroup: readLinuxGroup, + }), + ).toBe(false); + expect( + isProcessGroupRunning(4821, { + platform: 'linux', + killImpl: vi.fn(), + readLinuxProcessGroup: () => null, + }), + ).toBe(true); + + stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821'); + expect(readLinuxGroup()).toBe(true); + }); + test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => { const child = Object.assign(new EventEmitter(), { exitCode: null, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index d5e73f03b..9193eb134 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -5,7 +5,11 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; -import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs'; +import { + isProcessGroupRunning, + spawnChild, + terminateChildTree, +} from '../scripts/start-dev-stack.mjs'; import { buildTauriArguments, runTauriDev, @@ -200,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { }); expect(result).toBe(42); - expect(() => process.kill(-cliChild.pid, 0)).toThrow(); + expect(isProcessGroupRunning(cliChild.pid)).toBe(false); } finally { if (Number.isInteger(cliChild?.pid)) { try { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index d07892f34..ac8add1d8 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -58,6 +58,17 @@ function position( }; } +function automaticPosition( + resourceId: string, + x: number, + y: number, +): ProjectResourceCanvasPosition { + return { + ...position(resourceId, x, y), + manuallyPlaced: false, + }; +} + afterEach(() => { cleanup(); window.__TAURI__ = undefined; @@ -72,6 +83,117 @@ describe('useProjectResourceCanvasLayout', () => { ); }); + it('waits for dependency graph initialization before reading or writing layout', async () => { + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 0, []); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 1, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const shallowResource = resource('resource-a'); + const deepResource = { ...shallowResource, dependencyDepth: 2 }; + const { result, rerender } = renderHook( + ({ initializationReady, resources }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + initializationReady, + rederiveAutomaticPositions: true, + }), + { + initialProps: { + initializationReady: false, + resources: [shallowResource], + }, + }, + ); + + expect(invoke).not.toHaveBeenCalled(); + expect(result.current.layout.positions).toEqual([]); + + rerender({ initializationReady: true, resources: [deepResource] }); + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions[0]).toMatchObject({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }); + expect(invoke.mock.calls[0]?.[0]).toBe( + 'read_local_project_resource_canvas_layout', + ); + }); + + it('rederives automatic dependency positions while preserving manual positions', async () => { + const resourceA = { ...resource('resource-a'), dependencyDepth: 2 }; + const resourceB = resource('resource-b'); + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 4, [ + automaticPosition('resource-a', 0, 0), + position('resource-b', 600, 40), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 5, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA, resourceB], + rederiveAutomaticPositions: true, + }), + ); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + it('rejects an unsafe revision from the initial IPC read without writing', async () => { const resourceA = resource('resource-a'); const invoke = vi.fn(async (command: string) => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 11a3771c3..2ca695d2a 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-07-28` +更新时间:`2026-08-03` ## 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-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 ```ts type ProjectResourceCanvasLayout = { @@ -237,16 +237,30 @@ type UpdateProjectResourceCanvasLayoutResult = - 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。 - 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。 - dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。 -- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。 +- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。 - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 -- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。 +- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。 - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 - 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 - CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 +#### 5.2.5 资源依赖关系图层 + +- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 +- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust。 +- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 +- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 +- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。 +- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。 +- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。 +- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 +- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。 +- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。 +- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 + ### 5.3 资源类型与替换兼容性(P1) ```ts @@ -382,9 +396,20 @@ 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 或其它持久化。 +7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。 +8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 + ## 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 a95e80b49..cb0b8213c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state + +- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。 +- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depth;dependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。 +- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDB;type 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。 +- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。 + > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 ## 记录格式 @@ -16,6 +23,19 @@ --- +## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG + +- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。 +- 决策: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`。 + +--- + ## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存 - 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5dc3cbba8..1aa39dcd2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,22 @@ - 关联:相关文件、文档、提交或 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`。 + +## 依赖图未就绪时不能先初始化资源布局 + +- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。 +- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。 +- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。 +- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。 +- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`。 + ## Jenkins 异步备份不能用 nohup 脱离作业 - 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。 @@ -3970,6 +3986,14 @@ - 处理:先以 CAS 单独 commit `queued -> executing`,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。 - 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。 +## 大型 async 状态机不能在同一 Tokio poll 调用栈连续嵌套(2026-08-03) + +- 现象:Supervisor collaboration durable isolated spawn 恢复测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。 +- 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue 和 Agent 主循环各自形成大型 async poll frame;恢复路径在同一次 poll 调用链直接进入下一层状态机,累计超过 worker 默认栈。 +- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。边界必须保留结构化取消语义;当前使用 boxed future 与 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。 +- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`。 + ## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离 - 现象:把 `openai_chat / openai_responses / anthropic` 直接当 Provider 身份,注册第二个同协议 endpoint 时发生 ID 冲突;或为方便调用把 API Key、base URL、raw-log 目录放进全局状态,并行请求后日志串目录。 @@ -4003,8 +4027,8 @@ - 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。 - 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。 -- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 -- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 +- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。Linux 容器的 PID 1 可能不及时回收已退出的孤儿后代,`kill(-PGID, 0)` 会继续命中 zombie;Linux 探活必须扫描 `/proc//stat`,只把同 PGID 的非 zombie 成员视为仍在运行,`/proc` 不可读时继续失败关闭。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 +- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL、Linux 同组只剩 zombie 时视为已停止,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。 ## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9f20d3f3a..07448ea03 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -359,8 +359,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 sidecar;2026-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 与回滚合同完备前不向普通用户开放。 @@ -369,7 +369,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 @@ -382,7 +382,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 会话有效的状态冒充项目持久化。 @@ -390,6 +390,25 @@ 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.1 + +2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB: + +- `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 保留在返回锚点;路径不穿过卡片。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 边裁剪,不在首轮预先引入额外复杂度。 + +2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。 + +拖动热路径不再把 preview 写入工作台父组件 state。卡片使用稳定回调与 `React.memo`,动画帧直接更新拖动卡片 CSS 变量和 section 临时 extent;SVG 图层暴露命令式 preview 句柄,复用 Rust 局部连接索引和已缓存 path 节点,只更新受影响 reference / task-flow 几何。ResizeObserver 仍为单图层单实例。4096 张真实卡片测试必须证明非拖动卡片零重渲染、静态 SVG 子树不重建,Chromium p95 继续以 `<16.7ms` 为门槛。实时 DOM 几何不得通过 Tauri IPC 往返 Rust。 + ## 分阶段实施 1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。 @@ -402,7 +421,7 @@ game-project/ - 用户能创建本地 Web 游戏项目。 - 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。 -- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源;所有展示数据来自当前 manifest 或当前项目导入附件。 +- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源;dependency 模式展示可验证的资源引用和聚合任务流,搜索、选择与拖动同步更新线段;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。 - 首个 `code-prototype` 任务未完成时运行入口不可进入并给出可感知提示;完成后可进入运行表现层,真实预览直接加载到客户端内受限运行容器。 - 审批档位通过独立弹出面板切换,默认严格审批;界面选择不得绕过 Runtime 现有确认门禁。 - 聊天输入 `/plan` 可在普通聊天消息里查看下一轮分工计划,不读取任务文件、不启动 run、不修改项目,也不新增普通用户计划面板。 @@ -746,6 +765,7 @@ game-project/ - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 - 2026-07-17 起,同一 Runtime 文档的“V1.32 Runtime 强制 Supervisor 协作合同”作为 mixed swarm 可靠性事实源。项目可用 `.agent/collaboration-policy.json` 约束首波 static/isolated 模式、数量和 required static Agent;Runtime 在任何 child 副作用前整批校验并把合同指纹固化进 Provider batch v2。当前父 run 一旦形成 delivery/group,正式 `project-supervisor` 默认只负责编排、状态认领和验证,不再直接执行项目 mutation;专业 Agent/isolated child 权限与唯一 Supervisor 最终回复边界保持不变。 - 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。 +- 2026-08-03 恢复执行补充约束:整个 pending action continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界输入必须先装箱,避免泛型 helper 在真正 spawn 前仍把大型 future 保留在调用方 async frame;边界同时必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义,当前使用 boxed future 与 `JoinSet` 承担该约束。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。 - 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`。 - V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。 - 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 943e1857f..cf62e1db0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -60,7 +60,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。