diff --git a/.gitignore b/.gitignore index b3dd2a2dc..9ef28446b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ temp*build*/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ /apps/mobile-shell/.expo-export-smoke/ +/apps/preview-deployer-web/dist/ +/apps/preview-deployer-web/node_modules/ /server-rs/.spacetimedb/ /server-rs/.data/ /public/generated-animations diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a541abce3..d2cead3a5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1237,10 +1237,10 @@ if ( clientWindow.width !== 1280 || clientWindow.height !== 800 || clientWindow.minWidth !== 1280 || - clientWindow.minHeight !== 800 + clientWindow.minHeight !== 720 ) { throw new Error( - 'AI game creator shell client window must keep the landscape workbench size', + 'AI game creator shell client window must default to 1280x800 and stay at least 1280x720', ); } @@ -1472,6 +1472,22 @@ if ( ); } +const gameChatReleaseWindows = gameChatReleaseTauriConfig.app?.windows ?? []; +const gameChatReleaseClientWindow = gameChatReleaseWindows[0]; +if ( + gameChatReleaseWindows.length !== 1 || + gameChatReleaseClientWindow?.label !== 'client' || + gameChatReleaseClientWindow?.url !== 'index.html' || + gameChatReleaseClientWindow?.width !== 1280 || + gameChatReleaseClientWindow?.height !== 800 || + gameChatReleaseClientWindow?.minWidth !== 1280 || + gameChatReleaseClientWindow?.minHeight !== 720 +) { + throw new Error( + 'AI game creator game-chat client window must default to 1280x800 and stay at least 1280x720', + ); +} + if ( tauriConfig.version !== '0.1.0' || packageConfig.version !== '0.1.0' || diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2b9088e97..2d16ab7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,3 +1,5 @@ +#[path = "build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; @@ -64,5 +66,14 @@ fn main() { .join("agent_runtime_prompt_bundle.rs"); fs::write(&output_path, compiled.rust_source) .unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}")); + if !tauri_build::is_dev() { + let frontend_dist = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下") + .join("dist"); + println!("cargo:rerun-if-changed={}", frontend_dist.display()); + frontend_dist_guard::validate_frontend_dist(&frontend_dist) + .unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}")); + } tauri_build::build() } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs new file mode 100644 index 000000000..f8527c120 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const FORBIDDEN_TEST_PROJECT_PATH: &[u8] = b"/tmp/genarrative-ai-game-draft"; + +pub fn validate_frontend_dist(dist_root: &Path) -> Result<(), String> { + let root_metadata = fs::symlink_metadata(dist_root) + .map_err(|error| format!("读取生产 frontendDist 失败:.: {error}"))?; + if metadata_is_link(&root_metadata) { + return Err("生产 frontendDist 不能是符号链接:.".to_string()); + } + if !root_metadata.is_dir() { + return Err("生产 frontendDist 必须是目录:.".to_string()); + } + + validate_frontend_dist_directory(dist_root, dist_root) +} + +fn validate_frontend_dist_directory(dist_root: &Path, directory: &Path) -> Result<(), String> { + let mut entries = fs::read_dir(directory) + .map_err(|error| { + format!( + "读取生产 frontendDist 目录失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + })? + .map(|entry| { + entry.map(|entry| entry.path()).map_err(|error| { + format!( + "读取生产 frontendDist 目录项失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + }) + }) + .collect::, String>>()?; + entries.sort(); + + for path in entries { + let relative_path = relative_dist_path(dist_root, &path); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取生产 frontendDist 资源失败:{relative_path}: {error}"))?; + if metadata_is_link(&metadata) { + return Err(format!( + "生产 frontendDist 不能包含符号链接:{relative_path}" + )); + } + if metadata.is_dir() { + validate_frontend_dist_directory(dist_root, &path)?; + continue; + } + if !metadata.is_file() { + return Err(format!( + "生产 frontendDist 只能包含普通文件和目录:{relative_path}" + )); + } + + let bytes = fs::read(&path) + .map_err(|error| format!("读取生产 frontendDist 文件失败:{relative_path}: {error}"))?; + if contains_bytes(&bytes, FORBIDDEN_TEST_PROJECT_PATH) { + return Err(format!( + "生产 frontendDist 包含禁止的测试默认路径 /tmp/genarrative-ai-game-draft:{relative_path}" + )); + } + } + + Ok(()) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn relative_dist_path(dist_root: &Path, path: &Path) -> String { + path.strip_prefix(dist_root) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + .map(|relative| relative.display().to_string()) + .unwrap_or_else(|| ".".to_string()) +} + +fn metadata_is_link(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + + false +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index d0c2877d2..ca5ce15c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -84,7 +84,8 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_codex_cli_version_at(executable: &Path) -> Result { - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .arg("--version") .stdin(Stdio::null()) .stderr(Stdio::null()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index 3fd2ab07a..33c6cd649 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -394,6 +394,84 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; + let durable_receipt = match read_autonomous_playtest_receipt(root, contract) { + Ok(Some(receipt)) if receipt.revision == revision_after.revision => receipt, + Ok(Some(_)) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执不属于当前 revision".to_string(), + detail: None, + }; + } + Ok(None) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执回读失败".to_string(), + detail: None, + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执无法验证".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + { + let _project_lock = + match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "preview.validate.initial-version", + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本暂时无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + let locked_revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) if revision.revision == durable_receipt.revision => revision, + Ok(revision) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩回执落盘后项目 revision 已变化,未登记旧版本" + .to_string(), + detail: Some(format!( + "receiptRevision={}, currentRevision={}", + durable_receipt.revision, revision.revision + )), + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但登记版本前无法复核项目 revision" + .to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + if let Err(error) = + ensure_initial_game_iteration_version_at(root, locked_revision.revision) + { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } if contract_belongs_to_runtime { if let Err(error) = clear_agent_runtime_failed_playtest_at( root, 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 498616bd4..3497aae4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,91 @@ use super::*; +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .document_dir() + .map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取系统文档目录:{error}")) +} + +pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { + let mut candidate = if path.exists() && path.is_dir() { + Some(path) + } else { + path.parent() + }; + while let Some(directory) = candidate { + if directory.is_dir() { + return Some(directory.to_path_buf()); + } + candidate = directory.parent(); + } + None +} + +pub(crate) fn create_automatic_local_game_project_at( + projects_root: &Path, +) -> Result { + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + fs::create_dir_all(projects_root).map_err(|error| { + format!( + "创建自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = format!("GameAgent 项目 {short_id}"); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, +) -> Result { + create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) +} + #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, @@ -20,6 +106,9 @@ pub(crate) fn import_local_godot_project( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; + if discover_local_godot_project_root(root)?.is_none() { + return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) } @@ -65,12 +154,14 @@ pub(crate) fn inspect_local_project_directory( return Err("项目目录不能包含控制字符".to_string()); } let recent_run_trace = recent_game_creator_run_trace(root); + let godot_project_root = discover_local_godot_project_root(root)?; Ok(LocalProjectDirectoryStatus { project_path: root.to_string_lossy().into_owned(), exists: root.exists(), is_directory: root.is_dir(), is_game_creator_project: is_game_creator_project_directory(root), - is_godot_project: is_godot_project_directory(root), + is_godot_project: godot_project_root.is_some(), + godot_project_root, project_name: game_creator_project_name(root), manifest_error: game_creator_project_manifest_error(root), recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()), @@ -78,16 +169,6 @@ pub(crate) fn inspect_local_project_directory( }) } -pub(crate) fn is_godot_project_directory(root: &Path) -> bool { - if !root.is_dir() { - return false; - } - let project_file = root.join("project.godot"); - fs::symlink_metadata(project_file) - .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) -} - pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { if !root.is_dir() { return false; @@ -127,9 +208,23 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); let mut dialog = app.dialog().file().set_title("选择游戏项目目录"); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } if let Some(window) = app.get_webview_window("client") { dialog = dialog.set_parent(&window); } @@ -216,7 +311,7 @@ pub(crate) fn get_local_game_manifest( return Err(format!("不支持通过 manifest 执行命令:{command_id}")); } enforce_project_permission_policy(root, command_id)?; - read_manifest_for_project(root) + read_manifest_for_project_with_godot_root_calibration(root) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 04ce73338..4404da0e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -373,7 +373,8 @@ pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> { fn check_game_creator_codex_app_server_available() -> Result<(), String> { let executable = crate::agent::game_creator_codex_cli_executable_path()?; - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .args(["app-server", "--help"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) 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 2d533e865..bb23a8e46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -32,6 +32,7 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, @@ -127,6 +128,7 @@ struct LocalProjectDirectoryStatus { is_directory: bool, is_game_creator_project: bool, is_godot_project: bool, + godot_project_root: Option, project_name: Option, manifest_error: Option, recent_run_status: Option, @@ -2177,6 +2179,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + create_automatic_local_game_project, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index c078f5b02..3725368d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,10 +1,196 @@ use super::*; +use super::filesystem::validate_portable_project_path_component; + const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); +#[cfg(windows)] +fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn godot_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) +} + +fn inspect_godot_project_marker(root: &Path) -> Result { + let project_file = root.join("project.godot"); + let metadata = match fs::symlink_metadata(&project_file) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Godot 项目文件失败:{}: {error}", + project_file.display() + )); + } + }; + if godot_metadata_is_link(&metadata) { + return Err(format!( + "Godot 项目文件不能是符号链接或 reparse point:{}", + project_file.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "Godot 项目文件必须是普通文件:{}", + project_file.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(format!( + "Godot 项目文件不能是硬链接文件:{}", + project_file.display() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.number_of_links() != Some(1) { + return Err(format!( + "Godot 项目文件必须可确认是无硬链接普通文件:{}", + project_file.display() + )); + } + } + Ok(true) +} + +fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result { + let name = name + .to_str() + .ok_or_else(|| "Godot 项目子目录名称必须是有效 UTF-8".to_string())?; + validate_portable_project_path_component(name) + .map_err(|error| format!("Godot 项目子目录名称不可跨平台使用:{error}"))?; + Ok(name.to_string()) +} + +fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + if value == "." { + return Ok(()); + } + if value.is_empty() + || Path::new(value).is_absolute() + || value.contains('/') + || value.contains('\\') + || matches!(value, "..") + { + return Err("manifest godotProjectRoot 只能是 . 或单个安全相对目录名".to_string()); + } + validate_portable_project_path_component(value) + .map_err(|error| format!("manifest godotProjectRoot 无效:{error}")) +} + +pub(crate) fn discover_local_godot_project_root( + workspace_root: &Path, +) -> Result, String> { + if workspace_root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !workspace_root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(workspace_root) { + return Err("项目目录不能包含控制字符".to_string()); + } + + let workspace_metadata = match fs::symlink_metadata(workspace_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取项目目录失败:{}: {error}", + workspace_root.display() + )); + } + }; + if godot_metadata_is_link(&workspace_metadata) { + return Err("项目目录不能是符号链接或 reparse point".to_string()); + } + if !workspace_metadata.is_dir() { + return Ok(None); + } + + if inspect_godot_project_marker(workspace_root)? { + return Ok(Some(".".to_string())); + } + + let mut matches = Vec::new(); + let entries = fs::read_dir(workspace_root).map_err(|error| { + format!( + "读取 Godot 工作区目录失败:{}: {error}", + workspace_root.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Godot 工作区目录项失败:{}: {error}", + workspace_root.display() + ) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取 Godot 项目候选失败:{}: {error}", path.display()))?; + if godot_metadata_is_link(&metadata) { + continue; + } + if !metadata.is_dir() || !inspect_godot_project_marker(&path)? { + continue; + } + matches.push(validate_godot_project_child_name(&entry.file_name())?); + } + matches.sort(); + if matches.len() > 1 { + return Err(format!( + "工作区一层子目录中发现多个 Godot 项目:{}", + matches.join("、") + )); + } + let Some(relative_root) = matches.pop() else { + return Ok(None); + }; + + let candidate_root = workspace_root.join(&relative_root); + let candidate_metadata = fs::symlink_metadata(&candidate_root).map_err(|error| { + format!( + "复核 Godot 项目目录失败:{}: {error}", + candidate_root.display() + ) + })?; + if godot_metadata_is_link(&candidate_metadata) || !candidate_metadata.is_dir() { + return Err(format!( + "Godot 项目目录在发现期间发生替换或不是普通目录:{}", + candidate_root.display() + )); + } + if !inspect_godot_project_marker(&candidate_root)? { + return Err(format!( + "Godot 项目文件在发现期间消失:{}", + candidate_root.join("project.godot").display() + )); + } + Ok(Some(relative_root)) +} + #[derive(Debug)] struct ManifestWriteLock { _file: File, @@ -255,11 +441,11 @@ pub(crate) fn import_local_godot_project_at( return Err("项目目录不能包含控制字符".to_string()); } if !root.is_dir() { - return Err("Godot 项目目录不存在或不是文件夹".to_string()); - } - if !is_godot_project_directory(root) { - return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string()); + return Err("Godot 工作区目录不存在或不是文件夹".to_string()); } + let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { + "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() + })?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } @@ -269,7 +455,11 @@ pub(crate) fn import_local_godot_project_at( let manifest_path = root.join(".agent/manifest.json"); if manifest_storage_exists(&manifest_path)? { - let manifest = read_manifest(&manifest_path)?; + let mut manifest = read_manifest(&manifest_path)?; + if manifest.godot_project_root.as_deref() != Some(godot_project_root.as_str()) { + manifest.godot_project_root = Some(godot_project_root); + write_manifest(&manifest_path, &manifest)?; + } return Ok(InitLocalProjectResult { project_path: root.to_string_lossy().into_owned(), manifest_path: manifest_path.to_string_lossy().into_owned(), @@ -286,6 +476,7 @@ pub(crate) fn import_local_godot_project_at( "projectId": project_id, "name": name, "projectKind": "godot", + "godotProjectRoot": godot_project_root, }), )?; } @@ -299,7 +490,8 @@ pub(crate) fn import_local_godot_project_at( })?; } - let manifest = new_game_creation_app_manifest(project_id, name); + let mut manifest = new_game_creation_app_manifest(project_id, name); + manifest.godot_project_root = Some(godot_project_root); write_manifest(&manifest_path, &manifest)?; Ok(InitLocalProjectResult { @@ -361,6 +553,17 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result Result { + let godot_project_root = discover_local_godot_project_root(root)?; + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + ensure_manifest_seed_tasks(root, &mut manifest); + manifest.godot_project_root = godot_project_root; + write_manifest(&manifest_path, &manifest)?; + Ok(manifest) +} + pub(crate) fn read_existing_manifest_for_project( root: &Path, ) -> Result { @@ -371,6 +574,53 @@ pub(crate) fn read_existing_manifest_for_project( Ok(manifest) } +/// Registers the first formally playable project version after the current +/// revision has produced a durable successful browser-playtest receipt. +/// Replays are idempotent: once any formal version exists, validation never +/// rewrites or appends another initial record. +pub(crate) fn ensure_initial_game_iteration_version_at( + root: &Path, + project_revision: u64, +) -> Result { + if project_revision == 0 { + return Err("首个可玩版本必须绑定大于 0 的项目 revision".to_string()); + } + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + if !manifest.versions.is_empty() { + return Ok(false); + } + let resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }) + .collect(); + manifest.versions.push(GameIterationVersion { + version_id: format!("initial-{project_revision}"), + parent_version_id: None, + project_revision, + resource_bindings, + created_reason: GameIterationVersionCreatedReason::Initial, + created_at: unix_timestamp(), + edit_prompt: None, + }); + match write_manifest(&manifest_path, &manifest) { + Ok(()) => Ok(true), + Err(error) => { + // Another writer may have committed the same logical transition + // after our read. Treat an installed formal version as a replay; + // every other storage failure remains visible to the Runtime. + if read_manifest(&manifest_path).is_ok_and(|current| !current.versions.is_empty()) { + Ok(false) + } else { + Err(error) + } + } + } +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, @@ -871,6 +1121,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result( where F: FnOnce(), { + validate_manifest_godot_project_root(manifest.godot_project_root.as_deref()) + .map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?; validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 367e46815..b226c4158 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -14,15 +14,29 @@ fn godot_import_test_path(label: &str) -> PathBuf { )) } +fn write_godot_project(root: &Path, name: &str) { + fs::create_dir_all(root).expect("create Godot project root"); + fs::write( + root.join("project.godot"), + format!("[application]\nconfig/name=\"{name}\"\n"), + ) + .expect("write project.godot"); +} + +fn assert_manifest_godot_root(root: &Path, expected: &str) { + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + assert_eq!(manifest.godot_project_root.as_deref(), Some(expected)); + let payload: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("read manifest payload"), + ) + .expect("parse manifest payload"); + assert_eq!(payload["godotProjectRoot"], expected); +} + #[test] fn imports_godot_project_without_creating_parallel_game_directories() { let root = godot_import_test_path("valid"); - fs::create_dir_all(&root).expect("create Godot project root"); - fs::write( - root.join("project.godot"), - "[application]\nconfig/name=\"Existing\"\n", - ) - .expect("write project.godot"); + write_godot_project(&root, "Existing"); fs::create_dir(root.join("scenes")).expect("create existing scenes"); fs::write(root.join("scenes/main.tscn"), "[gd_scene format=3]\n") .expect("write existing scene"); @@ -32,8 +46,20 @@ fn imports_godot_project_without_creating_parallel_game_directories() { assert_eq!(result.project_path, root.to_string_lossy()); assert_eq!(result.manifest.project_id, "godot-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(root.join(".agent/agent.db")) + .expect("read Godot import Agent DB") + .lines() + .next() + .expect("Godot import Agent DB record"), + ) + .expect("parse Godot import Agent DB record"); + assert_eq!(import_record["recordType"], "project.import"); + assert_eq!(import_record["godotProjectRoot"], "."); + assert_manifest_godot_root(&root, "."); assert_eq!( fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), "[gd_scene format=3]\n" @@ -49,9 +75,314 @@ fn imports_godot_project_without_creating_parallel_game_directories() { .expect("reopen imported Godot project"); assert_eq!(reopened.manifest.project_id, "godot-project"); assert_eq!(reopened.manifest.name, "Existing"); + assert_eq!(reopened.manifest.godot_project_root.as_deref(), Some(".")); fs::remove_dir_all(root).ok(); } +#[test] +fn imports_a_unique_direct_child_godot_project_into_the_workspace_root() { + let workspace = godot_import_test_path("child"); + let godot_root = workspace.join("game-source"); + write_godot_project(&godot_root, "Nested"); + fs::create_dir(godot_root.join("scenes")).expect("create nested scenes"); + fs::write(godot_root.join("scenes/main.tscn"), "[gd_scene format=3]\n") + .expect("write nested scene"); + + let result = import_local_godot_project_at(&workspace, "nested-godot", "Nested") + .expect("import nested Godot project"); + + assert_eq!(result.project_path, workspace.to_string_lossy()); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("game-source") + ); + assert!(workspace.join(".agent/manifest.json").is_file()); + assert!(workspace.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(workspace.join(".agent/agent.db")) + .expect("read nested Godot import Agent DB") + .lines() + .next() + .expect("nested Godot import Agent DB record"), + ) + .expect("parse nested Godot import Agent DB record"); + assert_eq!(import_record["godotProjectRoot"], "game-source"); + assert!(!godot_root.join(".agent").exists()); + assert_manifest_godot_root(&workspace, "game-source"); + assert_eq!( + fs::read_to_string(godot_root.join("scenes/main.tscn")).expect("read nested scene"), + "[gd_scene format=3]\n" + ); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn root_godot_project_takes_priority_over_direct_child_projects() { + let workspace = godot_import_test_path("root-priority"); + write_godot_project(&workspace, "Root"); + write_godot_project(&workspace.join("nested"), "Nested"); + + let result = import_local_godot_project_at(&workspace, "root-godot", "Root") + .expect("root Godot project should win"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + assert!(!workspace.join("nested/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { + let workspace = godot_import_test_path("multiple-children"); + write_godot_project(&workspace.join("alpha"), "Alpha"); + write_godot_project(&workspace.join("beta"), "Beta"); + + let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect_err("multiple direct child Godot projects must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!workspace.join(".agent").exists()); + assert!(!workspace.join("alpha/.agent").exists()); + assert!(!workspace.join("beta/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ignores_godot_projects_below_the_first_child_level() { + let workspace = godot_import_test_path("too-deep"); + write_godot_project(&workspace.join("parent/nested"), "Too Deep"); + + let error = import_local_godot_project_at(&workspace, "too-deep", "Too Deep") + .expect_err("second-level Godot project must not be imported"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn calibrates_existing_manifest_to_the_discovered_godot_root() { + let workspace = godot_import_test_path("calibrate-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("stale-root".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("reopen existing Godot workspace"); + + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.name, "Existing Project"); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("godot-game") + ); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { + let workspace = godot_import_test_path("ambiguous-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("game"), "Game"); + write_godot_project(&workspace.join("other"), "Other"); + let manifest_path = workspace.join(".agent/manifest.json"); + let original = fs::read(&manifest_path).expect("read original manifest"); + + let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect_err("ambiguous existing workspace must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read unchanged manifest"), + original + ); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_manifest_calibrates_missing_godot_root_without_moving_workspace_metadata() { + let workspace = godot_import_test_path("calibrate-on-open"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate Godot workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root.as_deref(), Some("godot-game")); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_plain_workspace_clears_a_stale_safe_godot_root() { + let workspace = godot_import_test_path("clear-stale-root"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("old-game".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate plain workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root, None); + let persisted = read_manifest(&manifest_path).expect("read calibrated manifest"); + assert_eq!(persisted.godot_project_root, None); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_rejects_unsafe_godot_project_roots() { + let workspace = godot_import_test_path("unsafe-manifest-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("unsafe", "Unsafe"); + manifest.godot_project_root = Some("../outside".to_string()); + + let error = write_manifest(&manifest_path, &manifest) + .expect_err("unsafe manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + assert!(!manifest_path.exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_read_rejects_unsafe_persisted_godot_project_root() { + let workspace = godot_import_test_path("unsafe-persisted-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest parent"); + let mut payload = serde_json::to_value(new_game_creation_app_manifest("unsafe", "Unsafe")) + .expect("serialize manifest fixture"); + payload["godotProjectRoot"] = serde_json::json!("../outside"); + fs::write( + &manifest_path, + format!( + "{}\n", + serde_json::to_string_pretty(&payload).expect("serialize unsafe manifest fixture") + ), + ) + .expect("write unsafe manifest fixture"); + + let error = read_manifest(&manifest_path) + .expect_err("unsafe persisted manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-marker"); + fs::create_dir_all(&workspace).expect("create linked marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked project.godot must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_symbolic_link_child_candidate_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + symlink(&target, workspace.join("linked-game")).expect("link Godot child"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("unrelated-linked-child"); + let unrelated_target = godot_import_test_path("unrelated-linked-target"); + write_godot_project(&workspace.join("game"), "Game"); + fs::create_dir_all(&unrelated_target).expect("create unrelated target"); + symlink(&unrelated_target, workspace.join("vendor-link")).expect("link unrelated directory"); + + let result = import_local_godot_project_at(&workspace, "regular", "Regular") + .expect("unrelated directory link must be ignored"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(unrelated_target).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_linked_marker_inside_a_regular_child_candidate() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child-marker"); + let godot_root = workspace.join("game"); + fs::create_dir_all(&godot_root).expect("create regular child candidate"); + fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked marker in a regular child must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn ignores_windows_reparse_child_candidate_without_writing_agent_metadata() { + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + if std::os::windows::fs::symlink_dir(&target, workspace.join("linked-game")).is_err() { + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); + return; + } + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("reparse Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + #[test] fn rejects_non_godot_directory_without_writing_agent_metadata() { let root = godot_import_test_path("invalid"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index f4fceaf1c..0e6bdd66e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -63,6 +63,58 @@ fn version_fixture( } } +#[test] +fn successful_first_playable_registration_creates_one_initial_version_with_asset_bindings() { + let root = unique_manifest_test_root("first-playable-version"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "asset-player".to_string(), + kind: "character".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/player.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("write first playable manifest"); + + let created = ensure_initial_game_iteration_version_at(&root, 7) + .expect("register verified first playable"); + assert!(created); + let replayed = ensure_initial_game_iteration_version_at(&root, 7) + .expect("replay verified first playable registration"); + assert!(!replayed); + + let installed = read_manifest(&manifest_path).expect("read versioned manifest"); + assert_eq!(installed.versions.len(), 1); + assert_eq!(installed.versions[0].version_id, "initial-7"); + assert_eq!(installed.versions[0].parent_version_id, None); + assert_eq!(installed.versions[0].project_revision, 7); + assert_eq!( + installed.versions[0].created_reason, + GameIterationVersionCreatedReason::Initial + ); + assert_eq!( + installed.versions[0].resource_bindings, + vec![GameIterationVersionResourceBinding { + slot_id: "asset:asset-player".to_string(), + resource_id: "asset-player".to_string(), + }] + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_versions_are_append_only_at_the_storage_boundary() { let root = unique_manifest_test_root("versions-append-only"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 7a8b75405..fc23efc69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1264,6 +1264,66 @@ fn init_local_game_project_creates_manifest_and_dirs() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() { + let documents_root = unique_project_path(); + fs::create_dir_all(&documents_root).expect("create documents fixture"); + let suggested_project_path = documents_root + .join("Genarrative GameAgent") + .join("gameagent-new"); + + assert_eq!( + closest_existing_project_picker_directory(&suggested_project_path), + Some(documents_root.clone()) + ); + + fs::remove_dir_all(documents_root).ok(); +} + +#[test] +fn automatic_local_game_project_allocates_unique_initialized_workspaces() { + let projects_root = unique_project_path(); + + let first = create_automatic_local_game_project_at(&projects_root) + .expect("create first automatic workspace"); + let second = create_automatic_local_game_project_at(&projects_root) + .expect("create second automatic workspace"); + + assert_ne!(first.project_path, second.project_path); + for result in [first, second] { + let root = PathBuf::from(&result.project_path); + assert_eq!(root.parent(), Some(projects_root.as_path())); + assert!(root + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("gameagent-"))); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join("game/index.html").is_file()); + assert!(result.manifest.name.starts_with("GameAgent 项目 ")); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[cfg(unix)] +#[test] +fn automatic_local_game_project_rejects_symlinked_projects_root() { + use std::os::unix::fs::symlink; + + let container = unique_project_path(); + let target = container.join("target"); + let projects_root = container.join("projects"); + fs::create_dir_all(&target).expect("create automatic workspace target"); + symlink(&target, &projects_root).expect("symlink automatic workspace root"); + + let error = create_automatic_local_game_project_at(&projects_root) + .expect_err("symlinked automatic workspace root must fail"); + + assert!(error.contains("普通文件夹")); + assert!(fs::read_dir(&target).expect("read target").next().is_none()); + fs::remove_dir_all(container).ok(); +} + #[test] fn init_local_game_project_requires_absolute_path() { let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo") @@ -1325,6 +1385,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { is_directory: false, is_game_creator_project: false, is_godot_project: false, + godot_project_root: None, project_name: None, manifest_error: None, recent_run_status: None, @@ -1338,6 +1399,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(file_status.exists); assert!(!file_status.is_directory); assert!(!file_status.is_game_creator_project); + assert!(!file_status.is_godot_project); + assert_eq!(file_status.godot_project_root, None); assert_eq!(file_status.project_name, None); assert_eq!(file_status.manifest_error, None); assert_eq!(file_status.recent_run_status, None); @@ -1349,6 +1412,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(dir_status.exists); assert!(dir_status.is_directory); assert!(!dir_status.is_game_creator_project); + assert!(!dir_status.is_godot_project); + assert_eq!(dir_status.godot_project_root, None); assert_eq!(dir_status.project_name, None); assert_eq!(dir_status.manifest_error, None); assert_eq!(dir_status.recent_run_status, None); @@ -1407,6 +1472,64 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_directory_status_reports_workspace_relative_godot_root() { + let root = unique_project_path(); + let godot_root = root.join("godot-game"); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + + let status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("inspect nested Godot workspace"); + + assert!(status.exists); + assert!(status.is_directory); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("godot-game")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect_err("ambiguous Godot workspace must fail inspection"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = import_local_godot_project( + root.to_string_lossy().into_owned(), + "ambiguous".to_string(), + "Ambiguous".to_string(), + ) + .expect_err("ambiguous Godot workspace must fail before locking"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); + assert!(!root.join(".agent").exists()); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_directory_open_path_requires_existing_absolute_directory() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index e6908bf47..d77a1e92c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -2593,7 +2593,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati } assert_eq!( fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"), - "drifted rules\\n" + "drifted rules\n" ); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index b2bc08304..ce93187e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -41,6 +41,15 @@ pub(crate) fn configure_windows_background_std_command( ) { } +pub(crate) fn new_windows_background_std_command(program: S) -> std::process::Command +where + S: AsRef, +{ + let mut command = std::process::Command::new(program); + configure_windows_background_std_command(&mut command, false); + command +} + pub(crate) fn configure_windows_background_tokio_command( command: &mut tokio::process::Command, create_process_group: bool, @@ -48,6 +57,64 @@ pub(crate) fn configure_windows_background_tokio_command( configure_windows_background_std_command(command.as_std_mut(), create_process_group); } +#[cfg(all(test, windows))] +mod windows_background_command_tests { + use super::*; + use std::fs; + use std::process::Stdio; + + const CHILD_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_CHILD"; + const RESULT_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_RESULT"; + const FIXTURE_TEST: &str = + "windows::windows_background_command_tests::background_command_console_fixture"; + + #[test] + #[ignore = "child-process fixture"] + fn background_command_console_fixture() { + if std::env::var_os(CHILD_ENV).is_none() { + return; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetConsoleWindow() -> windows_sys::Win32::Foundation::HWND; + } + let result_path = std::env::var_os(RESULT_ENV).expect("result path"); + let has_console_window = unsafe { !GetConsoleWindow().is_null() }; + fs::write( + result_path, + if has_console_window { + "console" + } else { + "hidden" + }, + ) + .expect("write console-window result"); + } + + #[test] + fn background_std_command_does_not_allocate_a_console_window() { + let directory = tempfile::tempdir().expect("create no-console test directory"); + let result_path = directory.path().join("console-window.txt"); + let mut command = new_windows_background_std_command( + std::env::current_exe().expect("current test binary"), + ); + command + .args(["--exact", FIXTURE_TEST, "--ignored"]) + .env(CHILD_ENV, "1") + .env(RESULT_ENV, &result_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = command.status().expect("run no-console child fixture"); + assert!(status.success(), "no-console child fixture must succeed"); + assert_eq!( + fs::read_to_string(result_path).expect("read console-window result"), + "hidden", + "CREATE_NO_WINDOW must keep background command probes from flashing a console window" + ); + } +} + #[cfg(all(windows, feature = "game-chat-release"))] pub(crate) fn configure_windows_suspended_background_std_command( command: &mut std::process::Command, diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index c5f5c340a..fad771711 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ], "security": { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json index 5d1096ce0..41996c7dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json @@ -15,7 +15,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ] }, diff --git a/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs new file mode 100644 index 000000000..dd2fa3f64 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs @@ -0,0 +1,141 @@ +#[path = "../build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; + +use frontend_dist_guard::validate_frontend_dist; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn new(label: &str) -> Self { + let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "genarrative-frontend-dist-{label}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("create frontend dist test directory"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn write_file(path: &Path, content: impl AsRef<[u8]>) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create frontend dist fixture parent"); + } + fs::write(path, content).expect("write frontend dist fixture"); +} + +#[test] +fn clean_frontend_dist_passes() { + let fixture = TestDirectory::new("clean"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/index.js"), + "const defaultProjectPath = '';", + ); + + validate_frontend_dist(&dist).expect("clean frontendDist should pass"); +} + +#[test] +fn nested_frontend_asset_with_test_default_path_fails_with_relative_path() { + let fixture = TestDirectory::new("marker"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/chunks/launcher.js"), + "const projectPath = '/tmp/genarrative-ai-game-draft';", + ); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject the test default path"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("/tmp/genarrative-ai-game-draft")); + assert!(portable_error.contains("assets/chunks/launcher.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[test] +fn test_fixture_outside_frontend_dist_does_not_fail_the_guard() { + let fixture = TestDirectory::new("fixture-scope"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("assets/index.js"), "const projectPath = '';"); + write_file( + &fixture.path().join("tests/project-path.fixture.txt"), + "/tmp/genarrative-ai-game-draft", + ); + + validate_frontend_dist(&dist) + .expect("explicit test fixtures outside frontendDist must remain allowed"); +} + +#[test] +fn missing_frontend_dist_fails_without_leaking_an_absolute_path() { + let fixture = TestDirectory::new("missing"); + let dist = fixture.path().join("dist"); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must exist before it can be embedded"); + assert!(error.contains("frontendDist")); + assert!(error.contains(":.")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(unix)] +#[test] +fn frontend_dist_symlink_fails_with_relative_path() { + use std::os::unix::fs::symlink; + + let fixture = TestDirectory::new("symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + symlink(&target, dist.join("assets/linked.js")).expect("create frontend dist symlink"); + + let error = + validate_frontend_dist(&dist).expect_err("production frontendDist must reject symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(windows)] +#[test] +fn frontend_dist_symlink_fails_when_windows_allows_creating_it() { + use std::os::windows::fs::symlink_file; + + let fixture = TestDirectory::new("windows-symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + if symlink_file(&target, dist.join("assets/linked.js")).is_err() { + return; + } + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject Windows symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2dc8e129b..e3a5c3cb7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,7 +32,6 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, - defaultProjectPath, PROJECT_SUPERVISOR_AGENT_ID, seedManifest, } from './app/constants'; @@ -605,9 +604,7 @@ export function App({ ); const eagerSupervisorProject = projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly; - const [projectPath, setProjectPath] = useState( - initialProjectPath || defaultProjectPath, - ); + const [projectPath, setProjectPath] = useState(initialProjectPath); const [workspaceProjectKind, setWorkspaceProjectKind] = useState(initialProjectKind); const [localProject, setLocalProject] = @@ -622,6 +619,7 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< @@ -3303,6 +3301,7 @@ export function App({ try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { return; diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 371ec89cf..909379253 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,7 +5,6 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); -export const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d97a3ca63..cbf05e886 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -96,6 +96,7 @@ export interface LocalProjectDirectoryStatus { isDirectory: boolean; isGameCreatorProject: boolean; isGodotProject: boolean; + godotProjectRoot: string | null; projectName: string | null; manifestError?: string | null; recentRunStatus: string | null; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 88d45c55c..8cfb64d71 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -413,16 +413,59 @@ export function agentRuntimeStateFromResult( result: AgentRuntimeResult, previous?: AgentRuntimeState | null, ): AgentRuntimeState { + const acceptedRunId = result.acceptedRunId?.trim(); + const acceptedTask = acceptedRunId + ? (result.recentTasks ?? result.state.recentTasks ?? []).find( + (task) => task.runId === acceptedRunId, + ) + : null; + const state = + acceptedTask && result.state.runId !== acceptedRunId + ? { + ...result.state, + agentId: acceptedTask.agentId, + taskId: acceptedTask.taskId, + sessionId: acceptedTask.sessionId, + runId: acceptedTask.runId, + source: acceptedTask.source, + parentAgentId: acceptedTask.parentAgentId ?? null, + parentRunId: acceptedTask.parentRunId ?? null, + delegationId: acceptedTask.delegationId ?? null, + goalId: acceptedTask.goalId ?? null, + goalRevision: acceptedTask.goalRevision ?? 0, + goalStatus: acceptedTask.goalStatus ?? null, + currentTask: acceptedTask.task, + currentGoal: acceptedTask.task, + status: acceptedTask.status, + phase: acceptedTask.phase, + currentAction: acceptedTask.currentAction, + waitingOn: agentRuntimeWaitingOnFromPhase(acceptedTask.phase), + nextStep: agentRuntimeNextStepFromPhase(acceptedTask.phase), + plan: [], + planRevision: undefined, + planExplanation: undefined, + planSteps: [], + activePlanStepIndex: null, + observations: [], + recentToolCalls: [], + pendingToolAction: null, + userInputRequest: null, + lastResponse: null, + error: acceptedTask.error, + startedAt: acceptedTask.updatedAt, + updatedAt: acceptedTask.updatedAt, + } + : result.state; return normalizeAgentRuntimeState( { - ...result.state, - taskQueue: result.taskQueue ?? result.state.taskQueue, - recentEvents: result.recentEvents ?? result.state.recentEvents, - recentTasks: result.recentTasks ?? result.state.recentTasks, + ...state, + taskQueue: result.taskQueue ?? state.taskQueue, + recentEvents: result.recentEvents ?? state.recentEvents, + recentTasks: result.recentTasks ?? state.recentTasks, userInputRequest: result.userInputRequest !== undefined ? result.userInputRequest - : result.state.userInputRequest, + : state.userInputRequest, }, previous, ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 42baedf55..f783c2693 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,9 +1,153 @@ -import { FolderKanban } from 'lucide-react'; +import { + CircleAlert, + Ellipsis, + FolderKanban, + FolderOpen, + FolderPlus, + Gamepad2, + Search, + X, +} from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; +import type { RecentProjectRow } from './model'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; import type { RecentProjectsController } from './useRecentProjects'; +function projectKindLabel(project: RecentProjectRow) { + if (project.projectKind === 'godot') { + return project.godotProjectRoot && project.godotProjectRoot !== '.' + ? `Godot · ${project.godotProjectRoot}` + : 'Godot'; + } + if (project.projectKind === 'web') { + return 'GameAgent'; + } + return '待识别'; +} + +function projectStatusTone(project: RecentProjectRow) { + if (project.canOpen) { + return 'ready'; + } + if (project.status === '检查中') { + return 'checking'; + } + return 'warning'; +} + +function ProjectMoreMenu({ + project, + recentProjects, +}: { + project: RecentProjectRow; + recentProjects: RecentProjectsController; +}) { + const [open, setOpen] = useState(false); + const [dropUp, setDropUp] = useState(false); + const menuRef = useRef(null); + const triggerRef = useRef(null); + + useLayoutEffect(() => { + if (!open) { + setDropUp(false); + return; + } + const trigger = triggerRef.current; + const menu = menuRef.current?.querySelector( + '.launcher-project-more-menu', + ); + const table = trigger?.closest('.launcher-project-table'); + if (!trigger || !menu || !table) { + return; + } + const triggerBounds = trigger.getBoundingClientRect(); + const tableBounds = table.getBoundingClientRect(); + const menuHeight = menu.offsetHeight; + const availableBelow = + Math.min(window.innerHeight, tableBounds.bottom) - triggerBounds.bottom; + const availableAbove = triggerBounds.top - Math.max(0, tableBounds.top); + setDropUp( + availableBelow < menuHeight + 8 && availableAbove > availableBelow, + ); + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + function closeIfOutside(event: PointerEvent) { + if ( + event.target instanceof Node && + !menuRef.current?.contains(event.target) + ) { + setOpen(false); + } + } + function closeOnEscape(event: KeyboardEvent) { + if (event.key !== 'Escape') { + return; + } + setOpen(false); + triggerRef.current?.focus(); + } + document.addEventListener('pointerdown', closeIfOutside); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('pointerdown', closeIfOutside); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + export function ProjectsPage({ status, homeProject, @@ -13,147 +157,137 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { + const rows = recentProjects.filteredProjectRows; + const hasStoredProjects = recentProjects.projectRows.length > 0; return (
-
-
-

项目组

-

+

+
+

项目

+

{recentProjects.recentWorkspaceRefreshing ? '正在检查项目状态' : status}

-
- - -
-
-
- -
- - - - - -
-
-
- {recentProjects.projectRows.length > 0 ? ( - recentProjects.projectRows.map((project) => ( -
+
+
- )) - ) : ( -
-
+
+
+
+ 项目 + 类型 + 状态 + 操作 +
+
+ {rows.length > 0 ? ( + rows.map((project) => ( +
+ + + {projectKindLabel(project)} + + + {projectStatusTone(project) === 'warning' ? ( + + +
+ )) + ) : hasStoredProjects ? ( +
+
+ ) : ( +
+
+ )} +
); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2fab75181..8073de31c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -63,6 +63,7 @@ export function WorkspaceLauncherShell({ setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, } = homeProject; const activeProjectContextRef = useRef(currentProjectContext); @@ -259,12 +260,13 @@ export function WorkspaceLauncherShell({ homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} onCreateDraft={createHomeDraft} + onCreateDraftAutomatically={createHomeDraftAutomatically} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); void openProject(path, 'open'); }} - onGodotProjectOpen={() => void homeProject.openGodotProject()} + onProjectPick={() => void homeProject.pickAndOpenProject()} /> ) : launcherView === 'projects' ? ( ( 'pick_local_project_directory', + agentChatProjectPath.trim() + ? { initialPath: agentChatProjectPath.trim() } + : undefined, ); if (!selectedPath) { setAgentChatStatus('已取消'); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts index ca64ace7f..b57dc2872 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import type { AgentBackgroundSubmitMode, AgentChatInteractionMode, @@ -18,8 +18,7 @@ import { deriveAgentStatusCards } from '../project-summary/agentPresentation'; export function useDeveloperAgentState() { const launcherAgentChatAgents = deriveAgentStatusCards(seedManifest, null); - const [agentChatProjectPath, setAgentChatProjectPath] = - useState(defaultProjectPath); + const [agentChatProjectPath, setAgentChatProjectPath] = useState(''); const [agentChatSelectedAgentId, setAgentChatSelectedAgentId] = useState( launcherAgentChatAgents[0]?.id ?? '', ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 70b61e872..871ad739c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -1,7 +1,8 @@ import { type Dispatch, - type FormEvent, type SetStateAction, + useCallback, + useRef, useState, } from 'react'; @@ -9,7 +10,7 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import { useEscapeToClose } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import type { @@ -57,7 +58,15 @@ export function useHomeProjectCreation({ setAgentChatProjectPath, rememberRecentWorkspace, }: UseHomeProjectCreationOptions) { - const [projectPath, setProjectPath] = useState(defaultProjectPath); + const [projectPath, setProjectPathState] = useState(''); + const [projectAction, setProjectAction] = useState< + 'opening' | 'creating' | null + >(null); + const projectActionRef = useRef(projectAction); + projectActionRef.current = projectAction; + const setProjectPath = useCallback((nextProjectPath: string) => { + setProjectPathState(nextProjectPath); + }, []); const [currentProjectContext, setCurrentProjectContext] = useState(null); const [activeProjectPreview, setActiveProjectPreview] = @@ -153,6 +162,57 @@ export function useHomeProjectCreation({ return imported; } + async function enterCreatedHomeProject( + invoke: TauriInvoke, + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const importedAttachments = await importHomeAttachments( + invoke, + result.projectPath, + attachments, + ); + const sessionId = await ensureProjectSupervisorActiveSessionId( + invoke, + result.projectPath, + ); + if (!sessionId) { + throw new Error('项目总控 Agent active Session 不可用'); + } + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: result.projectPath, + sessionId, + prompt: buildHomeConversationContent(mode, prompt, attachments), + runtime: null, + runProfile: 'autonomous-game-build', + }); + enterProjectDevelopment({ + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + mode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }); + resetLauncherHomeDraft(); + } + async function createHomeProjectFromDirectory( nextProjectPath: string, mode: HomeAgentMode, @@ -196,27 +256,14 @@ export function useHomeProjectCreation({ name: projectNameFromPath(trimmedProjectPath), }, ); - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); try { - const sessionId = await ensureProjectSupervisorActiveSessionId( + await enterCreatedHomeProject( invoke, - result.projectPath, + result, + mode, + prompt, + attachments, ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: result.projectPath, - sessionId, - prompt: buildHomeConversationContent(mode, prompt, attachments), - runtime: null, - runProfile: 'autonomous-game-build', - }); setStatus('已创建项目并交给项目总控 Agent'); } catch (error) { throw new Error( @@ -225,28 +272,6 @@ export function useHomeProjectCreation({ }`, ); } - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); - resetLauncherHomeDraft(); return '已创建项目并进入项目开发'; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -255,10 +280,37 @@ export function useHomeProjectCreation({ } } + async function finishAutomaticallyCreatedHomeProject( + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在启动项目总控 Agent'); + try { + await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); + setStatus('已自动创建工作区并开始工作'); + return '已自动创建工作区并开始工作'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } + async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, ) { + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -268,6 +320,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'creating'; + setProjectAction('creating'); setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -312,6 +366,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -320,6 +379,9 @@ export function useHomeProjectCreation({ await createProjectFromProjectPage(nextProjectPath); return; } + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -329,6 +391,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'opening'; + setProjectAction('opening'); setStatus('正在打开'); try { const directoryStatus = await invoke( @@ -343,18 +407,28 @@ export function useHomeProjectCreation({ setStatus('项目路径不是文件夹'); return; } - if (!directoryStatus.isGameCreatorProject) { - setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); - return; - } - let projectManifest = seedManifest; - try { + let projectManifest: GameCreationAppManifest; + if (directoryStatus.isGameCreatorProject) { projectManifest = await invoke( 'get_local_game_manifest', { projectPath: trimmedProjectPath }, ); - } catch { - // The Supervisor surface can still restore conversation/runtime state. + } else if ( + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ) { + const result = await invoke( + 'import_local_godot_project', + { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: projectNameFromPath(trimmedProjectPath), + }, + ); + projectManifest = result.manifest; + } else { + setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); + return; } setStatus('已打开项目'); enterProjectDevelopment({ @@ -362,7 +436,11 @@ export function useHomeProjectCreation({ projectName: directoryStatus.projectName || projectNameFromPath(trimmedProjectPath), - projectKind: directoryStatus.isGodotProject ? 'godot' : 'web', + projectKind: + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ? 'godot' + : 'web', manifest: projectManifest, projectRevision: await readCurrentProjectRevision( invoke, @@ -377,6 +455,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -413,11 +496,6 @@ export function useHomeProjectCreation({ pendingNonEmptyProject !== null, ); - function handleSubmit(event: FormEvent) { - event.preventDefault(); - void openProject(projectPath, 'open'); - } - async function createHomeDraft(draft: HomeDraft) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -425,6 +503,7 @@ export function useHomeProjectCreation({ } const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { return '已取消'; @@ -437,93 +516,90 @@ export function useHomeProjectCreation({ ); } - async function handlePickProjectDirectory() { + async function createHomeDraftAutomatically(draft: HomeDraft) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在创建工作区'); + const result = await invoke( + 'create_automatic_local_game_project', + ); + return finishAutomaticallyCreatedHomeProject( + result, + draft.mode, + draft.prompt, + draft.attachments, + ); + } + + async function pickAndOpenProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择'); + projectActionRef.current = 'opening'; + setProjectAction('opening'); + setStatus('正在选择项目'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } setProjectPath(selectedPath); - setStatus('已选择项目目录'); + projectActionRef.current = null; + setProjectAction(null); + await openProject(selectedPath, 'open'); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } - async function openGodotProject() { + async function pickAndCreateProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择 Godot 项目'); + projectActionRef.current = 'creating'; + setProjectAction('creating'); + setStatus('正在选择新项目文件夹'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } - const trimmedProjectPath = validateProjectPath(selectedPath); - if (!trimmedProjectPath) { - return; - } - const directoryStatus = await invoke( - 'inspect_local_project_directory', - { projectPath: trimmedProjectPath }, - ); - if (!directoryStatus.exists || !directoryStatus.isDirectory) { - setStatus('Godot 项目目录不存在或不是文件夹'); - return; - } - if (!directoryStatus.isGodotProject) { - setStatus('所选文件夹不是 Godot 项目'); - return; - } - const result = directoryStatus.isGameCreatorProject - ? { - projectPath: trimmedProjectPath, - manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, - manifest: await invoke( - 'get_local_game_manifest', - { projectPath: trimmedProjectPath }, - ), - } - : await invoke('import_local_godot_project', { - projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, - name: projectNameFromPath(trimmedProjectPath), - }); - setStatus('已打开 Godot 项目'); - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'godot', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode: null, - initialPrompt: '', - attachments: [], - recentRunStatus: directoryStatus.recentRunStatus, - recentRunStopReason: directoryStatus.recentRunStopReason, - createdAt: Date.now(), - }); + setProjectPath(selectedPath); + projectActionRef.current = null; + setProjectAction(null); + await createProjectFromProjectPage(selectedPath); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -538,13 +614,15 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + projectAction, + projectBusy: projectAction !== null, pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, - openGodotProject, - handleSubmit, - handlePickProjectDirectory, + pickAndOpenProject, + pickAndCreateProject, confirmCreateInNonEmptyFolder, cancelCreateInNonEmptyFolder, }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 8540e268c..ba71d0520 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -1,4 +1,10 @@ -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; +import { + type Dispatch, + type SetStateAction, + useEffect, + useMemo, + useState, +} from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus } from '../../app/types'; @@ -8,7 +14,6 @@ import { } from '../project-summary/projectSummary'; import { buildRecentProjectRows, - clearRecentWorkspaces, readRecentWorkspaces, removeRecentWorkspace, writeRecentWorkspace, @@ -23,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0); const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = useState(false); + const [projectSearchQuery, setProjectSearchQuery] = useState(''); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -62,13 +68,6 @@ export function useRecentProjects(setStatus: Dispatch>) { setRecentWorkspaceRefreshKey((current) => current + 1); } - function handleRecentWorkspaceRefresh() { - if (recentWorkspaces.length === 0 || recentWorkspaceRefreshing) { - return; - } - setRecentWorkspaceRefreshKey((current) => current + 1); - } - function handleRecentWorkspaceRemove(projectPath: string) { setRecentWorkspaces(removeRecentWorkspace(projectPath)); setRecentWorkspaceStatuses((current) => { @@ -77,11 +76,6 @@ export function useRecentProjects(setStatus: Dispatch>) { }); } - function handleRecentWorkspaceClear() { - setRecentWorkspaces(clearRecentWorkspaces()); - setRecentWorkspaceStatuses({}); - } - async function handleRevealProjectDirectory(projectPath: string) { const trimmedProjectPath = projectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -112,18 +106,38 @@ export function useRecentProjects(setStatus: Dispatch>) { recentWorkspaceStatuses, recentWorkspaceRefreshing, ); + const filteredProjectRows = useMemo(() => { + const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase(); + if (!normalizedQuery) { + return projectRows; + } + return projectRows.filter((project) => + [ + project.name, + project.path, + project.status, + project.godotProjectRoot ?? '', + project.projectKind === 'godot' + ? 'godot' + : project.projectKind === 'web' + ? 'gameagent web' + : 'unknown', + ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)), + ); + }, [projectRows, projectSearchQuery]); return { recentWorkspaces, recentWorkspaceRefreshing, projectRows, + filteredProjectRows, + projectSearchQuery, + setProjectSearchQuery, recentProjectRows: projectRows .filter((project) => project.canOpen) .slice(0, 3), rememberRecentWorkspace, - handleRecentWorkspaceRefresh, handleRecentWorkspaceRemove, - handleRecentWorkspaceClear, handleRevealProjectDirectory, }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 220a31edf..963ba78d4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -120,6 +120,16 @@ export function ProjectSupervisorView({ value={chatInput} placeholder="告诉项目总控接下来要做什么" onChange={(event) => onChatInputChange(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }} /> + + + ); + } + + return ( +
+
+
+ + + +
+ Docker 预览发布 + 内网环境 +
+
+
+ + +
+
+ +
+
+
+

PREVIEW DEPLOYER

+

构建并发布一个分支

+
+
+ } + label="进行中" + value={activeCount} + /> + } + label="运行中" + value={runningCount} + /> +
+
+ +
void handleSubmit(event)} + > +
+ +
+ + = 0 + ? `branch-option-${branchActiveIndex}` + : undefined + } + aria-autocomplete="list" + aria-controls="branch-search-list" + aria-expanded={branchSearch.open} + aria-haspopup="listbox" + aria-label="分支名" + role="combobox" + id="branch-input" + autoComplete="off" + disabled={submitting} + maxLength={200} + placeholder="master" + value={branch} + onChange={(event) => { + setBranchSearchEnabled(true); + setBranch(event.target.value); + if (commitHash) { + setCommitHash(''); + setCommitSearchEnabled(false); + } + }} + onFocus={() => { + setBranchSearchEnabled(true); + setBranchSearch((current) => ({ ...current, open: true })); + }} + onKeyDown={(event) => { + handleSearchKeyDown( + event, + branchSearch, + branchActiveIndex, + (index) => setBranchActiveIndex(index), + (item) => { + setBranch(item.name); + setCommitHash(''); + setBranchSearchEnabled(false); + setCommitSearchEnabled(false); + setBranchSearch((current) => ({ + ...current, + open: false, + })); + }, + () => + setBranchSearch((current) => ({ + ...current, + open: false, + })), + ); + }} + onBlur={() => + window.setTimeout( + () => + setBranchSearch((current) => ({ + ...current, + open: false, + })), + 120, + ) + } + /> +
+ { + setBranch(item.name); + setCommitHash(''); + setBranchSearchEnabled(false); + setCommitSearchEnabled(false); + setBranchSearch((current) => ({ ...current, open: false })); + }} + activeIndex={branchActiveIndex} + /> +
+
+ +
+ + = 0 + ? `commit-option-${commitActiveIndex}` + : undefined + } + aria-autocomplete="list" + aria-controls="commit-search-list" + aria-expanded={commitSearch.open} + aria-haspopup="listbox" + aria-label="Commit Hash" + role="combobox" + id="commit-input" + autoComplete="off" + disabled={submitting} + maxLength={40} + placeholder="留空则构建分支最新提交" + value={commitHash} + onChange={(event) => { + setCommitSearchEnabled(true); + setCommitHash(event.target.value); + }} + onFocus={() => { + setCommitSearchEnabled(true); + setCommitSearch((current) => ({ ...current, open: true })); + }} + onKeyDown={(event) => { + handleSearchKeyDown( + event, + commitSearch, + commitActiveIndex, + (index) => setCommitActiveIndex(index), + (item) => { + setCommitHash(item.commitHash); + setCommitSearchEnabled(false); + setCommitSearch((current) => ({ + ...current, + open: false, + })); + }, + () => + setCommitSearch((current) => ({ + ...current, + open: false, + })), + ); + }} + onBlur={() => + window.setTimeout( + () => + setCommitSearch((current) => ({ + ...current, + open: false, + })), + 120, + ) + } + /> +
+ { + setCommitHash(item.commitHash); + setCommitSearchEnabled(false); + setCommitSearch((current) => ({ ...current, open: false })); + }} + activeIndex={commitActiveIndex} + /> +
+ +
+ + {error ? ( +
+ + {error} +
+ ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} + +
+
+
+

发布记录

+

状态每 5 秒自动刷新

+
+
+ + {loading ? ( +
+ + 正在读取发布状态 +
+ ) : deployments.length === 0 ? ( +
+ + 还没有发布记录 +
+ ) : ( +
+ {deployments.map((deployment) => ( + setPendingUninstall(deployment)} + /> + ))} +
+ )} +
+
+ + {pendingUninstall ? ( +
setPendingUninstall(null)} + > +
event.stopPropagation()} + > + + + +

卸载这个预览?

+

+ 将停止并移除 {pendingUninstall.branch}{' '} + 对应的容器环境。 +

+
+ + +
+
+
+ ) : null} +
+ ); +} + +function DeploymentCard({ + deployment, + uninstalling, + onUninstall, +}: { + deployment: PreviewDeployment; + uninstalling: boolean; + onUninstall: () => void; +}) { + const displayedCommit = deployment.resolvedCommit || deployment.commitHash; + const canUninstall = + deployment.canUninstall ?? + !['stopped', 'uninstalling', 'cancelled'].includes(deployment.status); + + return ( +
+
+
+ + + +
+ + + {deployment.branch} + + + {displayedCommit ? shortCommit(displayedCommit) : '分支最新提交'} + +
+
+
+ + {STATUS_LABELS[deployment.status]} + + + + {HEALTH_LABELS[deployment.health]} + + {deployment.webPort ? ( + 端口 {deployment.webPort} + ) : null} + + 构建 #{deployment.id || '待分配'} + +
+
+ +
+ + + {formatTime(deployment.updatedAt || deployment.createdAt)} + + {deployment.message ? ( + {deployment.message} + ) : null} +
+ +
+ {deployment.webUrl ? ( + + 打开 Web + + ) : ( + Web 地址等待发布完成 + )} +
+ {deployment.jenkinsBuildUrl ? ( + + 构建详情 + + ) : null} + {canUninstall && deployment.id ? ( + + ) : null} +
+
+
+ ); +} + +function StatusIcon({ status }: { status: DeploymentStatus }) { + if (['queued', 'building', 'deploying', 'uninstalling'].includes(status)) { + return ; + } + if (status === 'running') { + return ; + } + if (status === 'failed' || status === 'cancelled') { + return ; + } + return ; +} + +function Summary({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: number; +}) { + return ( + + {icon} + {value} + {label} + + ); +} + +type SearchState = { + items: T[]; + loading: boolean; + open: boolean; + error: string; +}; + +function emptySearchState(): SearchState { + return { items: [], loading: false, open: false, error: '' }; +} + +function handleSearchKeyDown( + event: ReactKeyboardEvent, + state: SearchState, + activeIndex: number, + setActiveIndex: (index: number) => void, + onSelect: (item: T) => void, + onClose: () => void, +) { + if (!state.open || (!state.items.length && event.key !== 'Escape')) { + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveIndex((activeIndex + 1) % state.items.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1); + } else if (event.key === 'Enter' && activeIndex >= 0) { + event.preventDefault(); + const item = state.items[activeIndex]; + if (item) onSelect(item); + } else if (event.key === 'Escape') { + event.preventDefault(); + setActiveIndex(-1); + onClose(); + } +} + +function SearchResults({ + id, + type, + state, + activeIndex, + onSelect, +}: + | { + id: string; + type: 'branch'; + state: SearchState; + activeIndex: number; + onSelect: (item: BranchRef) => void; + } + | { + id: string; + type: 'commit'; + state: SearchState; + activeIndex: number; + onSelect: (item: CommitRef) => void; + }) { + if (!state.open) return null; + return ( +
+ {state.loading ? ( +
+ + 正在搜索 +
+ ) : state.error ? ( +
{state.error}
+ ) : state.items.length === 0 ? ( +
没有匹配结果
+ ) : type === 'branch' ? ( + state.items.map((item, index) => ( + + )) + ) : ( + state.items.map((item, index) => ( + + )) + )} +
+ ); +} + +function isActive(status: DeploymentStatus) { + return ['queued', 'building', 'deploying', 'uninstalling'].includes(status); +} + +function sortDeployments(deployments: PreviewDeployment[]) { + return [...deployments].sort( + (left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt), + ); +} + +function shortCommit(value: string) { + return value.slice(0, 10); +} + +function formatTime(value: string | number) { + const timestamp = toTimestamp(value); + if (Number.isNaN(timestamp)) { + return String(value || '时间未知'); + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp); +} + +function toTimestamp(value: string | number) { + if (typeof value === 'number') { + return value < 10_000_000_000 ? value * 1000 : value; + } + return Date.parse(value); +} + +function formatError(error: unknown) { + return error instanceof Error && error.message.trim() + ? error.message + : '请求失败'; +} + +function isUnauthorized(error: unknown) { + return error instanceof PreviewDeployerApiError && error.status === 401; +} diff --git a/apps/preview-deployer-web/src/api.test.ts b/apps/preview-deployer-web/src/api.test.ts new file mode 100644 index 000000000..88025e50b --- /dev/null +++ b/apps/preview-deployer-web/src/api.test.ts @@ -0,0 +1,158 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { + createDeployment, + createSession, + deleteSession, + getSession, + listDeployments, + searchBranches, + searchCommits, + uninstallDeployment, +} from './api'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('lists deployments from the same-origin control plane', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + deployments: [ + { + id: 'preview-1', + branch: 'feature/demo', + status: 'running', + health: 'healthy', + createdAt: '2026-08-15T00:00:00Z', + updatedAt: '2026-08-15T00:01:00Z', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const deployments = await listDeployments(); + + expect(deployments).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledWith( + '/api/preview-deployer/deployments', + expect.objectContaining({ credentials: 'same-origin' }), + ); +}); + +test('uses an http-only session without browser token persistence', async () => { + const fetchMock = vi + .fn() + .mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + init.method === 'DELETE' + ? null + : JSON.stringify({ authenticated: true }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + expect(await getSession()).toEqual({ authenticated: true }); + await createSession('team-access-token'); + await deleteSession(); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/preview-deployer/session', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ accessToken: 'team-access-token' }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/preview-deployer/session', + expect.objectContaining({ method: 'DELETE' }), + ); +}); + +test('submits only branch and optional commit to fixed endpoints', async () => { + const buildResponse = () => + new Response( + JSON.stringify({ + id: 'preview-2', + branch: 'master', + status: 'queued', + health: 'pending', + createdAt: '2026-08-15T00:00:00Z', + updatedAt: '2026-08-15T00:00:00Z', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(buildResponse())); + vi.stubGlobal('fetch', fetchMock); + + await createDeployment({ branch: 'master', commitHash: 'aa5221abc' }); + await uninstallDeployment('preview/2'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/preview-deployer/deployments', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ branch: 'master', commitHash: 'aa5221abc' }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/preview-deployer/deployments/preview%2F2/uninstall', + expect.objectContaining({ method: 'POST' }), + ); +}); + +test('searches branches and branch-scoped commits with encoded queries', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [{ name: 'feature/search', commitHash: 'abcdef123456' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + commitHash: 'abcdef123456', + shortHash: 'abcdef1', + subject: '搜索提交', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + expect(await searchBranches('feature/search')).toHaveLength(1); + expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/preview-deployer/refs/branches?q=feature%2Fsearch', + expect.objectContaining({ credentials: 'same-origin' }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def', + expect.objectContaining({ credentials: 'same-origin' }), + ); +}); diff --git a/apps/preview-deployer-web/src/api.ts b/apps/preview-deployer-web/src/api.ts new file mode 100644 index 000000000..d7aae1d60 --- /dev/null +++ b/apps/preview-deployer-web/src/api.ts @@ -0,0 +1,159 @@ +import type { CreateDeploymentInput, PreviewDeployment } from './types'; + +const API_BASE = '/api/preview-deployer'; + +export class PreviewDeployerApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'PreviewDeployerApiError'; + this.status = status; + } +} + +export interface PreviewDeployerSession { + authenticated: boolean; +} + +export interface BranchRef { + name: string; + commitHash?: string | null; +} + +export interface CommitRef { + commitHash: string; + shortHash: string; + subject?: string | null; +} + +export async function getSession(signal?: AbortSignal) { + const session = await request('/session', { + signal, + }); + return session ?? { authenticated: true }; +} + +export function createSession(accessToken: string) { + return request('/session', { + method: 'POST', + body: { accessToken }, + }); +} + +export function deleteSession() { + return request('/session', { method: 'DELETE' }); +} + +export async function listDeployments(signal?: AbortSignal) { + const payload = await request< + PreviewDeployment[] | { deployments: PreviewDeployment[] } + >('/deployments', { signal }); + return Array.isArray(payload) ? payload : payload.deployments; +} + +export async function searchBranches(query: string, signal?: AbortSignal) { + const payload = await request( + `/refs/branches?q=${encodeURIComponent(query)}`, + { signal }, + ); + return Array.isArray(payload) ? payload : payload.items; +} + +export async function searchCommits( + branch: string, + query: string, + signal?: AbortSignal, +) { + const payload = await request( + `/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`, + { signal }, + ); + return Array.isArray(payload) ? payload : payload.items; +} + +export function createDeployment(input: CreateDeploymentInput) { + return request('/deployments', { + method: 'POST', + body: input, + }); +} + +export function uninstallDeployment(deploymentId: string) { + return request( + `/deployments/${encodeURIComponent(deploymentId)}/uninstall`, + { method: 'POST', body: {} }, + ); +} + +interface RequestOptions { + method?: string; + body?: unknown; + signal?: AbortSignal; +} + +async function request( + path: string, + options: RequestOptions = {}, +): Promise { + const headers: Record = { Accept: 'application/json' }; + const init: RequestInit = { + method: options.method ?? 'GET', + headers, + signal: options.signal, + credentials: 'same-origin', + }; + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + const response = await fetch(`${API_BASE}${path}`, init); + const responseText = await response.text(); + const payload = parseJson(responseText); + if (!response.ok) { + throw new PreviewDeployerApiError( + readErrorMessage(payload) || `请求失败(HTTP ${response.status})`, + response.status, + ); + } + return unwrapPayload(payload); +} + +function parseJson(value: string): unknown { + if (!value.trim()) { + return null; + } + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function unwrapPayload(payload: unknown): T { + if (isRecord(payload) && 'data' in payload) { + return payload.data as T; + } + return payload as T; +} + +function readErrorMessage(payload: unknown) { + if (typeof payload === 'string') { + return payload.trim(); + } + if (!isRecord(payload)) { + return ''; + } + if (typeof payload.message === 'string') { + return payload.message; + } + if (isRecord(payload.error) && typeof payload.error.message === 'string') { + return payload.error.message; + } + return ''; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/preview-deployer-web/src/main.tsx b/apps/preview-deployer-web/src/main.tsx new file mode 100644 index 000000000..8217f6507 --- /dev/null +++ b/apps/preview-deployer-web/src/main.tsx @@ -0,0 +1,17 @@ +import './styles.css'; + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { PreviewDeployerApp } from './PreviewDeployerApp'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Missing #root container'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/preview-deployer-web/src/styles.css b/apps/preview-deployer-web/src/styles.css new file mode 100644 index 000000000..d552998c2 --- /dev/null +++ b/apps/preview-deployer-web/src/styles.css @@ -0,0 +1,686 @@ +:root { + color: #1c2533; + background: #f3f6fa; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: #f3f6fa; +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} +a { + color: inherit; +} + +.session-screen { + display: grid; + min-height: 100dvh; + place-content: center; + justify-items: center; + gap: 11px; + color: #778397; + padding: 20px; +} +.session-login-screen { + background: radial-gradient( + circle at 50% 20%, + rgba(37, 99, 235, 0.12), + transparent 34% + ), + #f3f6fa; +} +.login-card { + display: grid; + width: min(100vw - 32px, 400px); + gap: 19px; + border: 1px solid #dbe2eb; + border-radius: 16px; + background: #fff; + padding: 28px; + box-shadow: 0 24px 70px rgba(28, 37, 51, 0.13); +} +.login-card h1 { + margin: 5px 0 0; + color: #1c2533; + font-size: 25px; + letter-spacing: -0.025em; +} +.login-card label { + display: grid; + gap: 8px; + color: #39465a; + font-size: 13px; + font-weight: 700; +} +.login-brand-mark { + width: 44px; + height: 44px; +} +.login-error { + display: flex; + align-items: center; + gap: 7px; + color: #b42318; + font-size: 12px; +} + +.app-shell { + min-height: 100vh; +} +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #dde3eb; + background: rgba(255, 255, 255, 0.92); + padding: 0 max(20px, calc((100vw - 1120px) / 2)); + backdrop-filter: blur(12px); +} +.brand { + display: flex; + align-items: center; + gap: 11px; +} +.brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 10px; + color: #fff; + background: #2563eb; + box-shadow: 0 6px 16px rgba(37, 99, 235, 0.24); +} +.brand div { + display: grid; + gap: 1px; +} +.brand strong { + font-size: 15px; +} +.brand div span { + color: #778397; + font-size: 11px; +} +.topbar-actions { + display: flex; + gap: 8px; +} +.content { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; + padding: 42px 0 64px; +} +.hero { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} +.eyebrow { + margin: 0 0 7px; + color: #2563eb; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; +} +.hero h1 { + margin: 0; + font-size: clamp(27px, 4vw, 36px); + letter-spacing: -0.035em; +} +.summary-row { + display: flex; + gap: 9px; +} +.summary-chip { + display: flex; + min-height: 38px; + align-items: center; + gap: 7px; + border: 1px solid #dde3eb; + border-radius: 10px; + color: #657188; + background: #fff; + padding: 0 12px; + font-size: 12px; +} +.summary-chip strong { + color: #1c2533; + font-size: 15px; +} +.deploy-form { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto; + align-items: end; + gap: 16px; + border: 1px solid #dbe2eb; + border-radius: 14px; + background: #fff; + padding: 22px; + box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055); +} +.deploy-form label, +.search-field { + display: grid; + gap: 8px; + color: #39465a; + font-size: 13px; + font-weight: 700; +} +.search-field { + position: relative; + min-width: 0; +} +.search-results { + position: absolute; + z-index: 20; + top: calc(100% - 1px); + right: 0; + left: 0; + display: grid; + max-height: 230px; + overflow-y: auto; + border: 1px solid #cfd7e3; + border-radius: 0 0 9px 9px; + background: #fff; + box-shadow: 0 10px 25px rgba(28, 37, 51, 0.13); +} +.search-option { + display: flex; + min-height: 39px; + align-items: center; + gap: 8px; + border: 0; + color: #344054; + background: #fff; + padding: 7px 11px; + text-align: left; +} +.search-option:hover, +.search-option.active { + background: #eff6ff; +} +.search-option > span { + display: grid; + min-width: 0; + gap: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.search-option small { + overflow: hidden; + color: #8792a2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.search-state { + display: flex; + min-height: 39px; + align-items: center; + gap: 7px; + color: #8792a2; + padding: 0 11px; + font-size: 12px; +} +.search-state-error { + color: #b42318; +} +.deploy-form label small, +.search-field label small { + color: #8994a5; + font-weight: 500; +} +.input-shell { + display: flex; + height: 44px; + align-items: center; + gap: 8px; + border: 1px solid #cfd7e3; + border-radius: 9px; + color: #8994a5; + padding: 0 12px; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +.input-shell:focus-within { + border-color: #4b7bec; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11); +} +.input-shell input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + color: #202b3b; + background: transparent; +} +.input-shell input::placeholder { + color: #a4adba; +} +.primary-button, +.secondary-button, +.danger-button, +.icon-danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border-radius: 9px; + font-weight: 700; +} +.primary-button { + height: 44px; + border: 1px solid #2563eb; + color: #fff; + background: #2563eb; + padding: 0 18px; + box-shadow: 0 6px 15px rgba(37, 99, 235, 0.2); +} +.primary-button:hover { + background: #1d55ca; +} +.secondary-button { + min-height: 38px; + border: 1px solid #d5dce6; + color: #455266; + background: #fff; + padding: 0 13px; +} +.secondary-button:hover { + background: #f7f9fc; +} +.flash { + display: flex; + align-items: center; + gap: 9px; + margin-top: 14px; + border-radius: 9px; + padding: 11px 13px; + font-size: 13px; +} +.flash-error { + border: 1px solid #fecaca; + color: #b42318; + background: #fff3f2; +} +.flash-success { + border: 1px solid #bbf7d0; + color: #167044; + background: #f0fdf4; +} +.deployments-section { + margin-top: 38px; +} +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + margin-bottom: 14px; +} +.section-heading h2 { + margin: 0 0 4px; + font-size: 19px; +} +.section-heading p { + margin: 0; + color: #8490a2; + font-size: 12px; +} +.deployment-list { + display: grid; + gap: 11px; +} +.deployment-card { + border: 1px solid #dce3ec; + border-radius: 12px; + background: #fff; + padding: 17px 18px 14px; + box-shadow: 0 4px 14px rgba(28, 37, 51, 0.035); +} +.card-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} +.deployment-title-row { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; +} +.status-icon { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + color: #64748b; + background: #f0f3f7; +} +.status-running { + color: #168052; + background: #eafaf1; +} +.status-failed, +.status-cancelled { + color: #c43b32; + background: #fff0ef; +} +.status-building, +.status-deploying, +.status-queued, +.status-uninstalling { + color: #2563eb; + background: #edf4ff; +} +.deployment-title { + display: grid; + min-width: 0; + gap: 4px; +} +.deployment-title strong { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.deployment-title span { + color: #7c8798; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.badges { + display: flex; + flex: 0 0 auto; + gap: 7px; +} +.badge { + display: inline-flex; + min-height: 25px; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 0 9px; + font-size: 11px; + font-weight: 700; +} +.port-badge { + color: #475569; + background: #f1f5f9; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.record-badge { + color: #475569; + background: #f8fafc; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.badge-running { + color: #14734a; + background: #e9f9f0; +} +.badge-failed, +.badge-cancelled { + color: #b83129; + background: #fff0ef; +} +.badge-building, +.badge-deploying, +.badge-queued, +.badge-uninstalling { + color: #275db9; + background: #edf4ff; +} +.badge-stopped { + color: #667085; + background: #f0f2f5; +} +.health-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.health-healthy { + color: #14734a; + background: #e9f9f0; +} +.health-unhealthy { + color: #b83129; + background: #fff0ef; +} +.health-pending, +.health-unknown { + color: #7b6650; + background: #f8f3eb; +} +.deployment-meta { + display: flex; + align-items: center; + gap: 12px; + margin: 13px 0 0 47px; + color: #8792a2; + font-size: 11px; +} +.deployment-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.deployment-message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 14px; + border-top: 1px solid #edf0f4; + padding-top: 12px; +} +.primary-link, +.text-link { + display: inline-flex; + align-items: center; + gap: 5px; + color: #2563eb; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} +.primary-link:hover, +.text-link:hover { + text-decoration: underline; +} +.url-placeholder { + color: #98a2b2; + font-size: 12px; +} +.right-actions { + display: flex; + align-items: center; + gap: 15px; +} +.icon-danger-button { + min-height: 32px; + border: 1px solid transparent; + color: #b93a31; + background: transparent; + padding: 0 8px; + font-size: 12px; +} +.icon-danger-button:hover { + border-color: #ffd4d1; + background: #fff4f3; +} +.empty-state { + display: flex; + min-height: 170px; + align-items: center; + justify-content: center; + gap: 10px; + border: 1px dashed #cfd7e2; + border-radius: 12px; + color: #8490a2; + background: rgba(255, 255, 255, 0.55); + font-size: 13px; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + background: rgba(21, 29, 42, 0.42); + padding: 20px; + backdrop-filter: blur(3px); +} +.confirm-dialog { + width: min(100%, 410px); + border: 1px solid #e2e7ee; + border-radius: 14px; + background: #fff; + padding: 24px; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24); +} +.danger-icon { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 11px; + color: #bd382f; + background: #fff0ef; +} +.confirm-dialog h2 { + margin: 17px 0 8px; + font-size: 20px; +} +.confirm-dialog p { + margin: 0; + color: #667085; + font-size: 14px; + line-height: 1.65; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 24px; +} +.danger-button { + min-height: 38px; + border: 1px solid #c33b32; + color: #fff; + background: #c33b32; + padding: 0 13px; +} +.spin { + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .topbar { + padding: 0 16px; + } + .content { + width: min(100% - 28px, 1120px); + padding-top: 28px; + } + .hero { + align-items: flex-start; + flex-direction: column; + } + .deploy-form { + grid-template-columns: 1fr; + padding: 17px; + } + .card-main { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .badges { + margin-left: 47px; + } + .card-actions { + align-items: flex-start; + flex-direction: column; + } + .right-actions { + width: 100%; + justify-content: space-between; + } +} + +@media (max-width: 430px) { + .brand div span, + .secondary-button { + font-size: 0; + } + .secondary-button { + width: 38px; + padding: 0; + } + .summary-row { + width: 100%; + } + .summary-chip { + flex: 1; + } + .badges { + margin-left: 0; + } + .deployment-meta { + align-items: flex-start; + flex-direction: column; + margin-left: 0; + } +} diff --git a/apps/preview-deployer-web/src/types.ts b/apps/preview-deployer-web/src/types.ts new file mode 100644 index 000000000..6a3feb88a --- /dev/null +++ b/apps/preview-deployer-web/src/types.ts @@ -0,0 +1,32 @@ +export type DeploymentStatus = + | 'queued' + | 'building' + | 'deploying' + | 'running' + | 'uninstalling' + | 'stopped' + | 'failed' + | 'cancelled'; + +export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown'; + +export interface PreviewDeployment { + id?: string | null; + branch: string; + commitHash?: string | null; + resolvedCommit?: string | null; + status: DeploymentStatus; + health: DeploymentHealth; + webPort?: number | null; + webUrl?: string | null; + jenkinsBuildUrl?: string | null; + createdAt: string | number; + updatedAt: string | number; + message?: string | null; + canUninstall?: boolean; +} + +export interface CreateDeploymentInput { + branch: string; + commitHash?: string; +} diff --git a/apps/preview-deployer-web/src/validation.test.ts b/apps/preview-deployer-web/src/validation.test.ts new file mode 100644 index 000000000..dc3c85690 --- /dev/null +++ b/apps/preview-deployer-web/src/validation.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest'; + +import { validateBranch, validateCommitHash } from './validation'; + +test('accepts normal branch names and rejects unsafe values', () => { + expect(validateBranch('feature/preview-panel')).toBe(''); + expect(validateBranch('../master')).toBe('分支名格式不正确'); + expect(validateBranch('feature;rm')).toBe('分支名格式不正确'); +}); + +test('commit hash is optional but must be a git hash when present', () => { + expect(validateCommitHash('')).toBe(''); + expect(validateCommitHash('a0242e35')).toBe(''); + expect(validateCommitHash('not-a-hash')).not.toBe(''); +}); diff --git a/apps/preview-deployer-web/src/validation.ts b/apps/preview-deployer-web/src/validation.ts new file mode 100644 index 000000000..ad363332a --- /dev/null +++ b/apps/preview-deployer-web/src/validation.ts @@ -0,0 +1,27 @@ +const BRANCH_PATTERN = /^[0-9A-Za-z._/-]+$/u; +const COMMIT_PATTERN = /^[0-9a-fA-F]{7,40}$/u; + +export function validateBranch(value: string) { + const branch = value.trim(); + if (!branch) { + return '请输入分支名'; + } + if ( + branch.length > 200 || + !BRANCH_PATTERN.test(branch) || + branch.startsWith('/') || + branch.endsWith('/') || + branch.includes('..') + ) { + return '分支名格式不正确'; + } + return ''; +} + +export function validateCommitHash(value: string) { + const commitHash = value.trim(); + if (commitHash && !COMMIT_PATTERN.test(commitHash)) { + return 'Commit Hash 需为 7 到 40 位十六进制字符'; + } + return ''; +} diff --git a/apps/preview-deployer-web/src/vite-env.d.ts b/apps/preview-deployer-web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/preview-deployer-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/preview-deployer-web/tsconfig.json b/apps/preview-deployer-web/tsconfig.json new file mode 100644 index 000000000..8fb985872 --- /dev/null +++ b/apps/preview-deployer-web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": false, + "strict": true, + "noUncheckedIndexedAccess": true, + "jsx": "react-jsx", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/preview-deployer-web/vite.config.ts b/apps/preview-deployer-web/vite.config.ts new file mode 100644 index 000000000..99f2728cd --- /dev/null +++ b/apps/preview-deployer-web/vite.config.ts @@ -0,0 +1,36 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import react from '@vitejs/plugin-react'; +import { defineConfig, loadEnv } from 'vite'; + +const appRoot = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); + +export default defineConfig(({ mode }) => { + const env = { + ...loadEnv(mode, repoRoot, ''), + ...loadEnv(mode, appRoot, ''), + }; + const apiTarget = env.PREVIEW_DEPLOYER_API_TARGET ?? 'http://127.0.0.1:8410'; + + return { + root: appRoot, + envDir: repoRoot, + base: env.PREVIEW_DEPLOYER_WEB_BASE ?? '/build/', + plugins: [react()], + server: { + proxy: { + '/api/preview-deployer': { + target: apiTarget, + changeOrigin: true, + secure: false, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + }; +}); diff --git a/apps/preview-deployer-web/vitest.config.ts b/apps/preview-deployer-web/vitest.config.ts new file mode 100644 index 000000000..c4588ab8c --- /dev/null +++ b/apps/preview-deployer-web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index 1268bc485..93475d8d9 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -118,7 +118,7 @@ services: soft: 4096 hard: 4096 healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] interval: 10s timeout: 5s retries: 12 diff --git a/deploy/env/preview-deployer.env.example b/deploy/env/preview-deployer.env.example new file mode 100644 index 000000000..ca6a726db --- /dev/null +++ b/deploy/env/preview-deployer.env.example @@ -0,0 +1,19 @@ +# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。 +GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410 +GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/ +# 页面“构建详情”链接使用局域网地址,内部请求仍使用上面的 loopback 地址。 +GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/ +GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service +GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token> +# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。 +GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git +# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。 +GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts +GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令> +GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82 +GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82 +GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json +GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web +GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false +RUST_LOG=info,tower_http=info diff --git a/deploy/nginx/genarrative-preview-deployer-lan.conf b/deploy/nginx/genarrative-preview-deployer-lan.conf new file mode 100644 index 000000000..01cfb6817 --- /dev/null +++ b/deploy/nginx/genarrative-preview-deployer-lan.conf @@ -0,0 +1,35 @@ +location = /build { + return 301 /build/; +} + +location ^~ /build/ { + proxy_pass http://127.0.0.1:8410; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 90s; +} + +location ^~ /api/preview-deployer/ { + proxy_pass http://127.0.0.1:8410; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 90s; +} diff --git a/deploy/systemd/genarrative-preview-deployer.service b/deploy/systemd/genarrative-preview-deployer.service new file mode 100644 index 000000000..01e9990f1 --- /dev/null +++ b/deploy/systemd/genarrative-preview-deployer.service @@ -0,0 +1,26 @@ +[Unit] +Description=Genarrative Jenkins Preview Deployer +After=network-online.target jenkins.service +Wants=network-online.target +Requires=jenkins.service + +[Service] +Type=simple +User=jenkins +Group=jenkins +WorkingDirectory=/opt/genarrative/preview-deployer +EnvironmentFile=/etc/genarrative/preview-deployer.env +ExecStart=/opt/genarrative/preview-deployer/preview-deployer-server +Restart=on-failure +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=30 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/genarrative/preview-deployer + +[Install] +WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index 11d3dc53d..53b06587e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ ### 后台、宿主壳与运维 - [Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) +- [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md) - [后台多账号与 Tab 访问权限](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) - [宿主壳能力统一协议](./【前端架构】宿主壳能力统一协议-2026-06-17.md) - [Expo React Native 与 Tauri 宿主壳方案](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md) diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 2f47de891..55b058981 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-08-11`(资源卡预览、分区布局与依赖聚类,图片素材无限画布及全类型非破坏性资源编辑收口;现有 Godot 项目入口与项目根绑定) +更新时间:`2026-08-15`(项目管理页紧凑表格与桌面可用性收口;资源卡预览、分区布局、非破坏性资源编辑及 Godot 双根合同保持不变) ## 1. 产品定位 @@ -29,7 +29,7 @@ ### 3.1 预览与窗口 - 游戏预览直接在当前客户端窗口内展开。 -- 客户端仅交付横屏,默认与最小窗口均为 `1280×800`。 +- 客户端仅交付横屏,默认窗口为 `1280×800`,最小窗口为 `1280×720`。 - 右侧 Supervisor 和底部 Agent 状态栏常驻;窗口不得缩小到破坏该结构。 - 浏览器窄屏样式只作为开发兼容,不属于本版本产品合同。 @@ -108,17 +108,25 @@ - 第一批共享 chrome 固定覆盖画布动作按钮、工具栏、工具分组和分隔符。按钮的默认、悬停、键盘焦点、选中、禁用和主次色语义由共享层表达;宿主只提供图标、文案、事件与业务禁用条件。 - 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。 - 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。 -- 客户端正式产品仍只按 `1280×800` 横屏合同交付;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 +- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 - 普通用户界面不默认展示内部错误码或本机绝对路径;External Editor 配置只进入独立“运行时配置”对话框,不与素材画布主要动作并列,确需诊断的信息进入受控详情或开发模式。 - 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。 ### 3.8 现有 Godot 项目 -- 首页和项目组复用同一个“打开 Godot 项目”动作,用户选择的目录必须包含普通文件 `project.godot`。 -- 该目录直接成为当前项目根;后续文件读取、修改、命令 cwd、对话、Runtime 和最近项目记录都绑定这个根目录,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 -- 首次打开只在项目根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并保留。Godot 源码、场景、资源和项目配置继续使用原目录结构。 +- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的普通文件 `project.godot`。根目录命中优先;一层发现多个 Godot 工程时必须提示歧义,不猜测目标。 +- 工作区根始终绑定文件读取、修改、命令 cwd、对话、Runtime 和最近项目记录;`.agent/`、Agent DB 与日志也只写在这里。`project.godot` 所在目录额外以 `godotProjectRoot` 记录为相对工作区的 `.` 或单层目录名,不把项目作用域切到 Godot 子目录。 +- 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 - Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。 +### 3.9 项目管理页 + +- 项目页采用“标题与状态 + 本地搜索 + 打开项目 / 新建项目 + 紧凑项目表格”的桌面信息架构。可以参考成熟项目管理器的信息层级和密度,但不得复制 Unity 等外部产品的品牌、Logo、深色皮肤、专有图标、列名或文案;页面继续使用 Genarrative 平台主题和陶泥儿客户端壳。 +- 表格只展示现有权威数据:项目名称与工作区绝对路径、GameAgent / Godot 类型、目录 / manifest / 最近 Runtime 状态和行级操作。当前目录检查合同没有修改时间、编辑器版本、收藏或云状态,前端不得伪造这些列。 +- 可打开项目的主行点击后进入项目;显示目录和从最近列表移除收进键盘可达的行尾更多菜单。Escape 关闭菜单并把焦点还给触发按钮;移除只修改本机 WebView 最近项目记录,不删除磁盘文件。 +- 搜索只在已加载项目行中匹配名称、路径、类型、Godot 相对根和状态,不改 localStorage、不触碰项目目录、不新增后端或 Tauri 命令。无匹配状态提供清除搜索;真正空状态仍只引导用户使用顶部打开或新建,不追加第三个目录选择入口。 +- 正式视觉验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口:工具栏保持单行,表头与项目列对齐,项目列表内部滚动,document/body 不出现页面级横向或纵向溢出。截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果,不能只录静止页面或无结果点击。 + ## 4. 工作台状态机 ### 4.1 主视窗 @@ -366,7 +374,7 @@ type ProjectVersionResourceReplacement = { ### 5.4 游戏迭代版本(P1) -阶段六实现状态(2026-08-03):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、预览记录或 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮,也不自动把当前编辑态登记为版本。 +阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。 ```ts type GameIterationVersion = { @@ -486,7 +494,7 @@ type ProjectAgentMudPointAttribution = { 4. 两个窗口基于同一 revision 执行资源自动协调写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。 5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。 6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。 -7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。 +7. `1280×720` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见;`1280×800` 继续作为默认窗口基线。 ### 7.3 P1 资源依赖关系图验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1e1476abf..c76f2d5da 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -607,7 +607,7 @@ - 背景:新的《陶泥儿GameAgent-V1.0 项目开发界面需求》要求正式项目开发页同时承载资源管理、运行表现层、陶泥儿对话和子 Agent 状态,旧的“正式用户页只有主聊天与只读专业 Agent 列表”已不足以支撑目标交互。 - 决策:在现有 `apps/ai-game-creator-shell` 项目开发入口内扩展单一工作台,不新建平行客户端。首版从当前 manifest、导入附件和 Agent 状态派生界面,提供资源 / 运行切换、资源排列与聚焦、审批弹层和底部状态栏;真实游戏通过现有 localhost 预览 server 直接载入客户端内受限运行容器,不再调用系统外部浏览器。未具备正式写回契约的拖拽布局、版本资源替换、数值微调、泥点累计、Agent.md 和 Skill 管理不得在前端伪造成功。 -- 横屏窗口:当前独立 App 只交付横屏桌面工作台,`client` 默认与最小窗口固定为 `1280×800`。工作台按壳内剩余视口排布并收紧四周留白;消息区与 Runtime 区各自承担内部滚动,专业状态增长不得把输入区或底部 Agent 栏推到视口外。窄屏纵向布局不作为当前客户端验收目标。 +- 横屏窗口:当前独立 App 只交付横屏桌面工作台,`client` 默认窗口固定为 `1280×800`,最小窗口固定为 `1280×720`。工作台按壳内剩余视口排布并收紧四周留白;消息区与 Runtime 区各自承担内部滚动,专业状态增长不得把输入区或底部 Agent 栏推到视口外。窄屏纵向布局不作为当前客户端验收目标。 - 影响范围:`apps/ai-game-creator-shell` 正式项目开发页、项目工作台前端测试、AI 游戏创作智能体 App 实施计划和原生壳预览门禁。 - 验证方式:运行 AI game creator shell 定向测试与 typecheck、`npm run ai-game-creator-shell:check`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -6821,7 +6821,7 @@ - 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 - 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 - 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 -- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 +- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 ## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType @@ -7152,10 +7152,20 @@ - 根因:AGC 子目录存在独立 `node_modules` 时,AGC 源码会解析子目录 React,而仓库共享组件解析根目录 React;登录页不依赖共享 Hooks,进入首页后才触发 `Cannot read properties of null (reading 'useCallback')` 并白屏。 - 决策:AGC Vite 配置必须对 `react` 与 `react-dom` 启用 `resolve.dedupe`,release 和本地构建统一复用仓库根 React runtime。登录后内容保留错误边界,渲染异常必须显示可恢复提示,不能再次退化为无提示白屏。 +## 2026-08-13 AGC 自主首板试玩通过后登记初始版本 + +- 决策:自主游戏构建的当前 revision 只有在 `preview.validate` 成功且对应持久试玩回执已经写入后,才允许向 `.agent/manifest.json` 幂等追加首条 `initial` 版本;失败试玩、静态 smoke、checkpoint、预览启动和单独 revision 均不是版本事实。 +- 绑定与恢复:初始版本用 `initial-` 稳定标识,并以 `asset:` 槽位绑定当时全部已登记资源。已有任意正式版本或同一成功完成态恢复时不重复追加;后续子版本继续由明确资源派生事务创建。 + ## 2026-08-10 AGC 打开现有 Godot 项目 -- 项目根决策:项目首页与项目组提供同一“打开 Godot 项目”能力;被选目录需包含普通文件 `project.godot`,选中目录本身即为文件工具、命令与 Agent Runtime 的唯一项目根,不复制工程或建立第二套工作区。 -- 元数据决策:首次导入只创建并保留项目根下的 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目只读复用身份,不覆盖 manifest。 +- 项目管理 UI 决策(2026-08-15):项目页改为 Genarrative 自有浅色视觉的紧凑桌面项目表格,成熟项目管理器只作为“标题 / 搜索 / 主操作 / 表头 / 项目行 / 行尾菜单”的信息架构参考,不复制外部品牌、皮肤、专有图标和文案。顶部只有搜索、打开项目和新建项目;搜索不是路径输入。可打开项目的主行直接进入项目,显示目录与从最近列表移除收进行尾更多菜单。 +- 投影边界:表格只展示目录检查、manifest 与 Runtime 已有权威字段,包括名称、工作区路径、GameAgent / Godot 类型、`godotProjectRoot` 和状态;没有修改时间、编辑器版本、收藏、云同步或版本警告事实时不得伪造这些列。搜索只过滤当前项目行,不改 storage 或项目目录。 +- 验收边界:正式项目页只按 `1280×720` 最小横屏和 `1280×800` 默认窗口验收,工具栏单行、列表内部滚动、页面无横纵溢出。截图使用 populated fixture;视频必须展示搜索、菜单和可观察操作结果,不再接受长时间静止或只有 picker 调用的录屏。窄屏 / 竖屏不属于当前产品合同。 + +- 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 +- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 +- 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。 - HTTP 传输边界:根 H5 包不得依赖 Tauri guest 插件;AGC 独立包保留 `@tauri-apps/plugin-http`。Rust 插件显式关闭默认特性,只启用 `charset`、`cookies`、`http2` 和 `rustls-tls`,避免 `reqwest/system-proxy` 通过 Cargo feature union 把画布、Provider、Runtime 与本地回环夹具统一接入 OS 自动系统代理;如未来产品要求正式客户端继承系统代理,必须按各客户端明确设计并单独完成跨平台验证。 @@ -7618,9 +7628,9 @@ - 背景:新的《陶泥儿GameAgent-V1.0 项目开发界面需求》要求正式项目开发页同时承载资源管理、运行表现层、陶泥儿对话和子 Agent 状态,旧的“正式用户页只有主聊天与只读专业 Agent 列表”已不足以支撑目标交互。 - 决策:在现有 `apps/ai-game-creator-shell` 项目开发入口内扩展单一工作台,不新建平行客户端。首版从当前 manifest、导入附件和 Agent 状态派生界面,提供资源 / 运行切换、资源排列与聚焦、审批弹层和底部状态栏;真实游戏通过现有 localhost 预览 server 直接载入客户端内受限运行容器,不再调用系统外部浏览器。未具备正式写回契约的拖拽布局、版本资源替换、数值微调、泥点累计、Agent.md 和 Skill 管理不得在前端伪造成功。 -- 横屏窗口:当前独立 App 只交付横屏桌面工作台,`client` 默认与最小窗口固定为 `1280×800`。工作台按壳内剩余视口排布并收紧四周留白;消息区与 Runtime 区各自承担内部滚动,专业状态增长不得把输入区或底部 Agent 栏推到视口外。窄屏纵向布局不作为当前客户端验收目标。 +- 横屏窗口:当前独立 App 只交付横屏桌面工作台,`client` 默认窗口固定为 `1280×800`,最小窗口固定为 `1280×720`。工作台按壳内剩余视口排布并收紧四周留白;消息区与 Runtime 区各自承担内部滚动,专业状态增长不得把输入区或底部 Agent 栏推到视口外。窄屏纵向布局不作为当前客户端验收目标。 - 影响范围:`apps/ai-game-creator-shell` 正式项目开发页、项目工作台前端测试、AI 游戏创作智能体 App 实施计划和原生壳预览门禁。 -- 验证方式:运行 AI game creator shell 定向测试与 typecheck、`npm run ai-game-creator-shell:check`、`npm run check:encoding`、`git diff --check`,并用真实浏览器检查 `1280×800` 最小横屏与目标桌面视口布局。 +- 验证方式:运行 AI game creator shell 定向测试与 typecheck、`npm run ai-game-creator-shell:check`、`npm run check:encoding`、`git diff --check`,并用真实浏览器检查 `1280×720` 最小横屏、`1280×800` 默认窗口与目标桌面视口布局。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-07-17 AI 游戏创作 V1.31 使用同一父 run 收束静态与隔离协作 @@ -13784,14 +13794,16 @@ ## 2026-08-04 图片画布快速编辑改用统一正向白名单(2026-08-05 修订) - 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。 -- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。 +- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图。视频、单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁。 - 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。 - 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。 - 2026-08-07 修订:站内与 External v1 图片编辑请求统一只接受必填 `sourceReferenceId`,且该值必须是当前账号已登记的项目资源 ID 或素材 ID;objectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段全部返回 400,不提供兼容别名。后端用共享窄查询分别按两张表主键定点解析,双表同 ID、未命中、跨账号、对象缺失或越权均失败关闭;权威类型完全来自业务记录,只允许普通静态图片、规范图、角色图、完整图标图集、图标规范、宣发图和 UI 设计图。请求带 `targetLayerId` 时必须同时带 `projectId`,来源与目标优先比较 `assetObjectId`,任一方缺失才比较 canonical `(bucket, objectKey)`,且来源默认类型必须与目标资源默认类型一致;最终类型取目标覆盖值或目标资源类型。HTTP 入队写入版本化服务端解析快照,worker 执行前按同一业务 ID 再次定点解析,身份或类型漂移即失败关闭。旧任务只把已有资源 ID 或旧来源字符串本身当业务 ID 迁移,绝不按 objectKey 反查。Canvas Agent 必须从 `ImageMetadata.reference_id` 取主来源;红框标注上传图只作为辅助 `referenceImageSrcs`,不能冒充被编辑资源。仅以素材 ID 编辑时,队列审计与 `generationInputs.references` 保留素材 ID,不伪造项目资源关系。 - 2026-08-08 修订:`scene` 是单张静态图片素材,加入前端快速编辑正向白名单和 api-server 权威来源白名单;编辑结果继续保留 `scene`。用户标签覆盖侧的前端菜单与 SpacetimeDB 结构化布局白名单也必须显式覆盖 `scene`。通用图片生成接口仍拒绝 `scene`,避免绕过结构化场景生成契约。 - 2026-08-08 修订:画布 Agent 的 `edit_image` 工具内部可先形成待确认的 `EditorImageEditRequest`,但确认接口必须在通用入队前复用站内图片编辑的来源解析与目标预检,写入 `{ version, request, source }` 服务端快照。worker 只解析正式 versioned payload 与既有历史 payload,不接受当前 direct request 作为 fallback;这样未上线的 Agent 路径在生产端原位修正,不扩大消费端协议。 - 2026-08-08 修订:图片编辑业务引用按 `objectKey` 找到权威 `asset_object` 后,若资源或素材记录同时保存了 `assetObjectId`,必须验证两者指向同一对象;不一致时在 SpacetimeDB resolver 边界失败关闭,不能把未验证的记录 ID 与已验证的对象路径组合进 snapshot。缺少 ID 的历史记录继续以 canonical `(bucket, objectKey)` 作为对象身份。 -- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。 +- 2026-08-10 永久修订:画布视频快速编辑永久下线,上传、生成和历史视频图层均不再展示入口,程序化打开与直接提交同样失败关闭;不设置 feature flag、兼容桥或数据迁移。普通视频生成、视频“改造”、下载、`referenceVideoSrcs` 以及 V2 `videoReference` 恢复保持不变。External v1 从未正式声明“视频快速编辑”,只提供带可选 `referenceVideoSrcs` 的通用视频生成,因此不修改 `/api/external/v1` 路由、DTO、异步语义或 OpenAPI。当前服务已完全停止且无在途 / 可重试任务,不新增旧任务收口逻辑。 +- 2026-08-14 改造边界澄清:`image.edit` 是图片快速编辑通过 `targetLayerId` 原位替换后的配方记录,只属于已知 action,不属于可改造 action;结果图继续显示快速编辑,不显示改造。其它可改造生成产物按原 `action` 恢复对应 generation dialog 并生成新产物,不恢复旧 quick-edit 改造面板,也不额外承诺提交成功后保持面板打开。 +- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单覆盖视频、单个拆分图标、角色动作及音频不展示,打开与提交工作流覆盖视频等不支持类型绕过入口时仍拒绝;后端表驱动测试覆盖全部现役图片素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。 - 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`ImageCanvasSelectedLayerToolbarView.tsx`、`ImageCanvasContextMenusView.tsx`、`useImageCanvasGenerationWorkflow.ts`、`useImageCanvasGenerationSubmissionWorkflow.ts`。 ## 2026-08-05 game-chat Canvas 区间证明与 parent wake 持久收口 @@ -13831,7 +13843,7 @@ - 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 - 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 - 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 -- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 +- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 ## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType @@ -14135,8 +14147,23 @@ ## 2026-08-10 AGC 打开现有 Godot 项目 -- 项目根决策:项目首页与项目组提供同一“打开 Godot 项目”能力;被选目录需包含普通文件 `project.godot`,选中目录本身即为文件工具、命令与 Agent Runtime 的唯一项目根,不复制工程或建立第二套工作区。 -- 元数据决策:首次导入只创建并保留项目根下的 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目只读复用身份,不覆盖 manifest。 +- 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 +- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 +- 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 +## 2026-08-15 Jenkins 容器预览部署使用独立控制面 + +- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。 +- 部署入口只使用内网 `http://192.168.35.82/build/`,不配置公网域名;预览 Web 端口固定为 `8400..8499`,卸载后立即释放租约,运行状态由页面刷新时的实时 Web 探针更新。 +- Jenkins 的 Compose 编排、Dockerfile 入口和执行脚本固定取自受保护的 master 控制器 checkout,目标分支只作为应用源码构建上下文;控制 Job 只授予受信任开发者和专用服务账号。 +- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。 +- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。 +- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-08-17 预览发布记录使用 Jenkins 构建编号并有限保留 + +- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。 +- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。 +- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 94be7941e..d34d969e6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -844,11 +844,11 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。 - 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 -## 图片画布重绘创建独立占位,快速编辑不要新建生成器 +## 图片画布改造按原 action 生成新产物,快速编辑不要新建生成器 - 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。 - 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。 -- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId` 或 `sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。 +- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId` 或 `sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。`image.edit` 结果不允许改造;其它可改造产物按原 `action` 恢复 generation dialog 并生成新产物,去背景等异步入口继续使用各自现役 dialog / placement 链路,不恢复独立 quick-edit 改造面板。 - 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。 @@ -4383,6 +4383,22 @@ - 验证:定向测试必须覆盖用户段 `start + 5` 映射、同段占用漂移、父子启动器严格复用最终端口、动态 Tauri `--config`、marker 与预检地址一致、未知归属监听器拒绝复用、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。正常启动后退出,确认 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`。 +## Git 忽略的 AGC dist 会让 Windows 继续运行旧 Linux 路径交互(2026-08-14) + +- 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 `/tmp/genarrative-ai-game-draft`,新“打开项目 / 新建项目”交互也没有出现。 +- 原因:`apps/ai-game-creator-shell/dist/` 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 `dist`、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 `/tmp` 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。 +- 处理:产品前端项目页不持有默认路径;首页回车自动工作区仍只由原生 `document_dir()` 决定目标根,测试必须在 harness 内显式注入 `/tmp` fixture。普通与 game-chat release 在 Vite 构建后、Tauri 嵌入前扫描实际 `frontendDist`,命中精确旧路径、缺失产物或链接绕过时失败关闭;不能用仓库级源码扫描误伤合法测试 fixture。 +- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收检查 `1280×720` 最小横屏与 `1280×800` 默认窗口的紧凑项目表格和原生 picker。 +- 关联:`apps/ai-game-creator-shell/src/app/constants.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/src-tauri/tauri.conf.json`。 + +## 参考成熟项目管理器不能变成品牌复刻或伪数据列(2026-08-15) + +- 现象:根据外部产品截图重做项目页时,直接照搬其 Logo、深色皮肤、收藏 / 云图标、修改时间或编辑器版本列,页面看似成熟却展示 AGC 没有的数据真相,录屏也只剩静态摆拍。 +- 原因:把参考截图当成完整产品合同,没有先核对当前目录检查、manifest 和 Runtime 真正提供的字段,也没有定义视频必须证明的交互结果。 +- 处理:只借鉴标题、搜索、主操作、紧凑表头 / 项目行和行尾菜单的信息层级;继续使用 Genarrative theme/token,只展示项目名称、工作区路径、GameAgent / Godot 类型、Godot 相对根和真实状态。次要操作收进行尾菜单,搜索只做本地过滤。没有权威来源的列直接不做,不用占位或推断补齐。 +- 验证:DOM 与截图不得出现外部品牌或 unsupported 列;AppSurface 覆盖 populated / invalid / empty、搜索与菜单;Playwright 在 `1280×720` 测量无页面级溢出。视频控制在有用时长内,清楚展示搜索、清除、菜单、状态反馈和项目打开结果,每一段都有可观察变化。 +- 关联:`apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/model.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts`、`apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`。 + ## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) - 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5c9479dc3..db422cec6 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,11 +18,13 @@ - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 -- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。来源默认类型只用于绑定一致性,不得在应用同媒体族标签覆盖前提前拒绝;因此默认 `icon` 且无覆盖时仍禁止快速编辑,但同一图片图层覆盖为 `scene` 或 `spec` 后必须按覆盖类型放行。视频快速编辑只能走视频生成接口,角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 +- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图;视频、单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。来源默认类型只用于绑定一致性,不得在应用同媒体族标签覆盖前提前拒绝;因此默认 `icon` 且无覆盖时仍禁止快速编辑,但同一图片图层覆盖为 `scene` 或 `spec` 后必须按覆盖类型放行。视频仍支持普通生成、改造和下载,但不提供快速编辑;角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 +- 对生成资源执行“改造”时,按该产物的原 `action` 恢复对应 generation dialog;用户再次提交后生成新产物,不在原产物上原位修改,也不使用独立的 quick-edit 改造面板。改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中可能存在的后端拼接 Prompt、固定生成模板或模型默认提示词。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。generation dialog 的提交后显隐沿用原 action 的现役生成流程,不额外承诺“改造成功后保持面板打开”。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和图片类改造保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,图片类改造在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层改造作为 `quick-edit` 参考图提交,角色图层改造必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和图片类改造在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材不提供 `快速编辑`,只能通过角色动作生成链路继续编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是按配方的原 `action` 恢复对应 generation dialog,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 -- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。legacy adapter 只恢复映射后目标 action / 工具声明的输入,不得额外把当前图片附加为参考图,也不得把普通图片生成改写为 `quick-edit` 或 `character` 请求。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。定向测试必须至少覆盖未知图片回退普通 `image.generate` 和一种已知 `assetKind` 恢复原工具的真实提交闭环,并校验请求输入、V2 action / references、结果落点及 dialog / composer 终态。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -52,10 +54,10 @@ - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。 - 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。 -- snapper 在内存中把每对相邻横纵切线围成的采样单元各压成一个输出像素,最终直接编码并持久化唯一的逻辑分辨率 PNG;不得再用 nearest 或其它插值把逻辑图恢复到当前 RGBA 输入尺寸或业务交付尺寸。普通图片和角色仍先执行既有 Lanczos 交付尺寸归一,无法安全归一时仍保留 provider 实际尺寸;图标输入仍以已持久化平底原图的实际尺寸为准。这些规则只定义网格检测与 RGBA 采样输入,不构成成功输出与输入同尺寸的承诺。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 -- 像素模式的持久化增量必须为零:普通图片仍只上传一张最终逻辑分辨率主图;角色仍只保留原有 provider 原图与一张最终透明逻辑分辨率主图;图标仍只保留原有 provider 原图、一张最终透明逻辑分辨率图集和实际成功的切片。禁止另存输入尺寸恢复版、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 +- 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 +- 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 -- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建一张逻辑分辨率 PNG 派生结果;派生 PNG 的内在宽高取最终网格列数和行数,可以与源图及占位尺寸不同。音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 - 裁扩创建资源时,未携带 `sourceResourceId` 仍允许保存无正式来源引用的确定性结果;一旦携带该 ID,api-server 必须确认它属于当前 owner 和当前项目,否则以 `400` 拒绝,禁止同时持久化悬空 / 越权 `sourceResourceId` 和空来源配方。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 @@ -107,9 +109,9 @@ - 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 -- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源改造也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、改造、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 @@ -148,10 +150,10 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片类改造继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 -- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。前述“带背景原图和透明结果必须使用同一实际像素尺寸”只约束 BgFilter 输出校验、Alpha 回贴以及进入 snapper 前的内部共同坐标系;`pixelArt` 的 Alpha 回贴结果随后进入 snapper,最终逻辑分辨率 PNG 允许与带背景原图不同。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 +- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 @@ -180,7 +182,7 @@ - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图进入改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 -- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色仍先完成 Lanczos 交付尺寸归一,snapper 随后直接把一格一像素的逻辑网格编码为最终 PNG,不再 nearest 恢复到输入或交付尺寸,也不得执行其它后置尺寸插值。最终 PNG 宽高可以与规整输入不同,响应与资源快照按实际 PNG 尺寸记录。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item。 +- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 使用 nearest 把逻辑网格恢复到同一输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 @@ -198,15 +200,15 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 -- `details.operationResultAlreadyExists = true` 是另一种必须 GET-only 收口的明确结果:它说明同一稳定 operation 的旧权威资源已经存在、当前请求尚未 PUT。客户端不得把它显示为普通失败、不得自动 POST 重放,也不得据此声称当前请求的持久化已开始。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、改造、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 - 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 - 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,其新请求配方固定为 `version: 2 / action: image.perfect-pixel / fields: []`。账本外层版本不升级;嵌套 generationInputs 以共享严格白名单恢复,V2 完整接受稳定 `id`、有限数字、布尔值和可省略 `label`,旧 `{fields,references}` 请求继续按原形恢复以维持历史 fingerprint,未知键或畸形 V2 失败关闭。`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 - 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 - 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 - legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 -- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 +- 改造图片类生成资源后,按原 action 的 generation dialog 生成新结果图层并自动调整视图;面板提交后的关闭或保留行为继续沿用该 action 的现役生成流程。 +- 快速编辑或图片类改造站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留用户可添加的额外参考图入口;点击修改时把原图已登记业务 ID 作为 `/api/editor/images/edits` 的 `sourceReferenceId`,红框序号标注图上传后作为辅助 `referenceImageSrcs`。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。普通静态图片的 `assetKind` 为空;`"image"` 只是前端 `CanvasMediaType`,不是 `CanvasAssetKind`,两者不得混用。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath`、`objectKey` 和 `assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 426e7df7f..2dffcf68f 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -205,7 +205,7 @@ controller 配置: - `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。 - `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。 - `editor_character_animation_generation`:角色动作视频和帧素材生成。 -- `editor_video_generation`:画布视频生成和视频素材快速编辑。 +- `editor_video_generation`:画布视频生成。 - `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。 画板结果的业务真相仍是 `asset_object`、`editor_project_resource`、账号级 `editor_asset`、可选 `asset_entity_binding` 和对应的 legacy / structured canvas 表;`editor_generation_operation` 只是提交回执,不替代这些 read model。worker 在 Provider 与 OSS 完成后只做 prepare:使用 owner + operation kind + job ID + stable slot 派生 resource/asset ID,构造可选 object/binding、候选 layout 和 compact job result,然后一次调用 `persist_editor_generation_result_and_return`。该 procedure 必须在当前事务快照校验 owner、job kind、由 `request_payload_json` 重算的 fingerprint 与未过期 lease,最后与业务记录一起完成 job 和 receipt。任一验证、binding 或 canvas CAS 失败都回滚全部数据库事实;worker 不得随后再调用 `complete_external_generation_job_and_return`。前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload、receipt 或本地临时响应重建正式图层。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md new file mode 100644 index 000000000..93d5fa629 --- /dev/null +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -0,0 +1,121 @@ +# Jenkins 容器预览部署控制面技术方案 + +日期:`2026-08-15` + +## 目标 + +为内网同事提供独立 SPA,用于填写源码分支和可选 commit,触发 Jenkins 构建并发布完整 Docker Compose 预览栈。页面持续刷新排队、构建、发布和卸载状态,成功后展示容器健康状态与 Web 内网地址。 + +控制面不替代 Jenkins:Jenkins 继续负责源码检出、全量镜像构建、SpacetimeDB 模块发布、容器启动、健康验证、卸载和构建日志。浏览器不得持有 Jenkins 用户名、API Token、Crumb 或 Docker 权限。 + +## 架构 + +```text +同事浏览器 + -> preview-deployer SPA + -> preview-deployer-server 同源 API + -> 固定 Jenkins Job shared/Genarrative-Preview-Deployer + -> 独立 Compose project + 端口租约 + preview-result.json +``` + +- SPA 只通过内网 `http://192.168.35.82/build/` 提供页面,API 使用同源 `/api/preview-deployer/`;不配置公网域名,不复用后台管理员 Token 或业务 API 登录态。 +- `preview-deployer-server` 只代理固定 Jenkins Job,Jenkins 凭据只从服务端环境变量读取,不进入前端 bundle、JSON 响应或日志。 +- Jenkins Job 使用仓库现有 `deploy/container/` 全量容器资产,并通过独立 Compose project 支持多个分支实例并存;为保护端口和共享 Docker 状态,构建动作在首版中串行排队。 +- 页面只读取控制面归一化后的状态,不解析 Jenkins console,也不直连 Jenkins API。 + +## 部署身份与端口 + +预览实例 ID(`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。 + +Web 端口池固定为 `8400..8499`: + +1. Jenkins 在全局文件锁内读取受控状态目录。 +2. 已登记分支复用原端口;新分支选择状态表、宿主监听和 Docker 映射均未占用的端口。 +3. Jenkins 为实例使用独立 Compose project,例如 `genarrative-preview-a81f39c2d43e76ab`。 +4. 端口租约必须在容器完成真实绑定前持续受锁保护;失败构建不得把端口分配给第二个实例。 +5. 卸载成功后删除该实例的 Compose 栈、受控状态和端口租约。 + +同一分支重新构建时先完成新镜像构建,再停止旧 Compose 栈并启动新版本;源码或镜像构建失败不会提前删除原有健康实例。容器切换后的失败仍会在页面明确显示,不伪装成旧版本继续运行。 + +页面每次刷新运行中实例时,控制服务直接探测受控 Web URL;后续容器异常会将健康状态更新为 `unhealthy`,不会永久沿用 Jenkins 部署完成时的快照。 + +目标分支只提供应用构建上下文。Compose 编排文件、Dockerfile 入口和 Jenkins 执行脚本固定取自受保护的 `master` 控制器 checkout,避免普通分支替换编排文件挂载宿主路径或启用特权容器。该 Job 仍只应开放给受信任开发者,并建议在隔离构建节点运行。 + +SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:`。 + +SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=remote,migrate,break-clients`,避免 Jenkins 等待非本地目标交互确认;该预览路径不传 `--delete-data`。Jenkins 同时固定 `GENARRATIVE_PREVIEW_WEB_HOST=192.168.35.82`,不得用默认路由自动探测结果生成页面链接,以免 VPN 或容器网卡地址泄漏到同事可见 URL。 + +预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。 + +## Jenkins 参数与产物 + +固定 Job:`shared/Genarrative-Preview-Deployer`。 + +参数: + +- `ACTION`:`DEPLOY`、`UNINSTALL` 或 `STATUS`。 +- `SOURCE_BRANCH`:部署时必填。 +- `COMMIT_HASH`:部署时可选,7 到 40 位十六进制。 +- `DEPLOYMENT_ID`:卸载和状态查询时必填。 + +分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。 + +每次运行归档 `preview-result.json`,至少包含: + +```json +{ + "schemaVersion": 1, + "deploymentId": "feature-login-a81f39c2", + "branch": "feature/login", + "requestedCommit": "", + "resolvedCommit": "0123456789abcdef", + "status": "running", + "health": "healthy", + "webPort": 8403, + "webUrl": "http://192.168.35.82:8403", + "composeProject": "genarrative-preview-feature-login-a81f39c2", + "updatedAt": "2026-08-15T08:00:00.000Z" +} +``` + +控制面只读取固定 artifact 路径和受限字段;不得把完整 console 内容返回浏览器。 + +## 控制面 API + +- `POST /api/preview-deployer/session`:使用控制面访问口令建立 `HttpOnly + SameSite=Strict` 会话。 +- `GET /api/preview-deployer/session`:查询当前会话状态。 +- `DELETE /api/preview-deployer/session`:退出。 +- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。 +- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`。 +- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`。 +- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`。 +- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。 +- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。 + +页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。 + +发布记录卡片直接展示 Jenkins 构建编号、后端校验后的 `webPort` 和由控制服务转换的 Jenkins 局域网构建详情地址;内部 loopback Jenkins 地址不得返回浏览器。失败或取消且不存在可卸载实例的记录保留 7 天,成功卸载的内部记录保留 30 天,清理会在服务启动、读取列表和创建新构建时执行。仍可卸载的失败记录不会自动清理。`stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。 + +分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。 + +Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。 + +控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。 + +## 安全边界 + +- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。 +- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。 +- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。 +- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。 +- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。 +- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。 +- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。 +- 卸载只接受受控状态中存在且 ID 完全匹配的实例,并进行二次确认;禁止执行任意 Docker、Git、shell 或 Compose project 参数。 + +## 验收 + +- 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。 +- 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。 +- Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。 +- 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4898c283e..025732101 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -10,7 +10,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ## 目标 -在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:所选含 `project.godot` 的目录直接成为项目根,Agent 使用标准运行档在该目录内继续修改,只新增并保留 `.agent/` 作为运行元数据,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。 +在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:用户选择的目录始终作为工作区根,`.agent/`、Session、Runtime、文件工具和外围资料都留在该根;客户端检查根目录及一层直接子目录中的普通文件 `project.godot`,将唯一命中的实际目录以工作区相对 `godotProjectRoot` 记录到 manifest。Agent 使用标准运行档继续修改,不创建 `game/`、`assets/`、`memory/`、`exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。 ## 技术选择 @@ -443,7 +443,7 @@ game-project/ - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。 - Tauri `read_local_conversation` 的公开消息 DTO 必须保留持久记录中的可选 `messageId`;该字段是区分合法 finalization 与普通 assistant 的必要身份,不能只留在 JSONL 私有记录中。旧记录没有 `messageId` 时继续返回 `null`,不得为兼容界面伪造 ID。 -- 当前独立 App 只交付横屏桌面工作台,Tauri `client` 默认与最小窗口统一为 `1280×800`;用户不能继续缩小到破坏双栏结构的窄屏尺寸。桌面工作台占满壳内剩余视口,四周只保留必要安全边距;右侧消息、Runtime 状态和输入区保持在同一栏内,专业状态过长时只滚动 Runtime 区,不得把输入区、底部 Agent 状态栏或整页撑出视口。`≤760px` 的浏览器样式仅保留开发兼容,不作为当前客户端交付口径。 +- 当前独立 App 只交付横屏桌面工作台,Tauri `client` 默认窗口为 `1280×800`,最小窗口为 `1280×720`;用户不能继续缩小到破坏双栏结构的窄屏尺寸。桌面工作台占满壳内剩余视口,四周只保留必要安全边距;右侧消息、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、第二套资产模型、前端版本替换真相或前端计费结论。资源关系图只能读取当前 manifest 与资源投影做派生展示,不得成为前端正式资源关系真相。 @@ -487,7 +487,7 @@ game-project/ 2026-08-03 阶段五加固,2026-08-05 明确截断信任边界:Rust read model 把 producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`。`producerMappingTruncated=true` 只表示有界 Agent DB 尾部不足以证明 producer:前端必须失败关闭 `producerAssignments`、`taskFlows` 及其 `cyclicTaskIds`,但继续严格校验并消费 Rust 从当前 manifest、精确引用和仍可信下限构建的 `dependencyDepths`;`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 同样继续有效。前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,只作为布局超边使用,不生成资源笛卡尔积或 SVG。 -2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。 +2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。2026-08-13 起,自主首板在当前 revision 的 `preview.validate` 成功结果和持久试玩回执均落盘后,幂等追加唯一首条 `initial` 版本,并以稳定 `asset:` 槽位绑定当时全部已登记资源;失败试玩、静态 smoke、checkpoint 和普通预览状态不得触发版本创建,恢复重放和已有版本项目也不得重复追加。 工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。 @@ -508,7 +508,7 @@ game-project/ ## v1 验收 - 用户能创建本地 Web 游戏项目。 -- 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。 +- 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×720` 最小横屏窗口、`1280×800` 默认窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。 - 资源管理可在按依赖 / 按类型之间切换、搜索资源并点击打开当前资源详情;dependency 画布只展示可验证的橙色精确资源引用,聚合任务流只参与自动布局并可在详情中读取。搜索过滤精确引用端点。资源卡不可拖动,Pointer Move 不更新坐标、线段或手动布局;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。 - “新增资源”和“精修资源”进入共用中央区域中的素材创作无限画布;图片导入、画布平移/缩放、图层选择/移动/缩放、撤销重做、基础编辑、生成、导出和 Tauri 正式保存形成完整闭环。保存后不刷新、不重开即可进入 manifest、依赖图、布局和选中流程,迟到结果不能覆盖用户后来切换的项目、状态、选择或搜索意图。 - 资源管理可在按依赖 / 按类型之间切换、搜索资源并点击打开当前资源详情;dependency 模式展示可验证的资源引用和聚合任务流,搜索过滤端点、选择高亮直接上下游。资源卡不可拖动,Pointer Move 不更新坐标、线段或手动布局;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。 @@ -572,11 +572,11 @@ game-project/ - 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。 - 结构化对话记录按授权本地项目路径追加 JSONL;正式聊天读取 Supervisor active Session 与只读 legacy 项目历史,开发单 Agent 对话读取对应 Agent Session。开发入口已支持本地 Session 新建、切换、归档和分叉,但不提供云端同步。 - Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单、`.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/.json` 和最近任务派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。 -- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件,再把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,写入最近项目并切到项目开发页。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 +- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。普通 `Enter` 在输入法组合态结束后自动在系统“文档/Genarrative GameAgent”下原子分配唯一工作区、初始化本地项目、导入附件,并把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,随后写入最近项目并切到项目开发页;`Shift+Enter` 保留换行。Windows 使用系统 Documents 路径,Linux 使用 XDG Documents,macOS 使用用户 Documents;前端不得显示或持久化 `/tmp` 作为默认项目路径,精确 `/tmp/genarrative-ai-game-draft` 只允许由测试显式注入为 fixture。普通与 game-chat release 都必须在 Vite 生成最新 `frontendDist` 后、Tauri 嵌入资源前扫描实际构建产物;命中该旧 Linux 默认路径或无法安全遍历产物时构建失败,禁止复用 gitignored 的旧 `dist`。原“开启创作”加号按钮保持手动选择目录:点击后弹出原生目录选择,目标目录存在且非空时必须二次确认,再调用 `init_local_game_project` 完成同一后续链路。项目页和首页通用“打开项目”没有手填默认路径;picker 未携带已有工作区时由操作系统决定初始位置。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。两条流程都不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 - 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。 - 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。 - debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents//` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。 -- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只读取已初始化项目并切到项目开发页,不打开第二窗口;新建项目仍沿用非空目录确认,不自动重建无效历史路径。 +- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口使用紧凑桌面项目表格管理最近项目。顶部工具栏只放本地搜索、“打开项目”“新建项目”,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个项目动作都先打开绑定当前 `client` 的非阻塞原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。项目表格只投影名称、路径、GameAgent / Godot 类型、目录 / manifest / Runtime 状态;可打开行点击进入项目,显示目录和移除最近记录收进行尾更多菜单。搜索仅过滤当前行,不修改 storage;空状态不追加第三个目录选择入口。正式验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口,列表内部滚动且无页面级溢出。 - 项目开发页保留左侧栏和顶部栏,顶部展示项目名、路径和最近 run 状态;中间只挂载 active `project-supervisor` Session 的正式对话面、Runtime 状态、确认/Needs input 和专业 Agent 协作只读状态,底部保留附件导入结果。真正的项目开发画布仍未落地;专业 Agent picker、完整计划和工具台继续留在开发入口。 ## v1 验收证据矩阵 @@ -651,7 +651,7 @@ game-project/ - `/publish` 聊天入口由 `appSurface.test.ts` 主窗口 smoke 覆盖:只基于当前已加载 manifest、最近 run trace、预览状态、资产来源和最近命令生成发布准备清单,提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿,不触发 Tauri 读写、预览启动、文件读取或新增普通用户面板。 - 主窗口策略快捷入口只填入 `/policy-confirm project.index`、`/policy-confirm asset.register`、`/policy-confirm memory.write`、`/policy-confirm preview.start`、`/policy-confirm preview.open`、`/policy-confirm preview.stop`、`/policy-confirm agent.run_status`、`/policy-confirm conversation.read` 或 `/policy-confirm conversation.write` 草稿;Agent 状态栏的“继续说明”只填入 `/agent-resume ` 草稿,不直接触发 run 生命周期写入。 - 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供直接对比、填入 `/diff`、确认回滚和填入 `/restore`;回滚继续走 `project.restore` 确认卡,不直接写项目文件。 -- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 只嵌入当前 loopback 游戏预览、用户侧预览命令不调用系统浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记一个用户窗口,登录后同一 WebView 渲染首页、项目组和项目开发页,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,旧窗口切换 command 仅保留兼容、不进入用户主流程。 +- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 只嵌入当前 loopback 游戏预览、用户侧预览命令不调用系统浏览器和 Tauri release `--no-bundle` 构建 smoke;该 smoke 必须实际经过最新 `frontendDist` 门禁。门禁 helper 的定向测试同时覆盖干净嵌套产物、嵌套 JS 命中旧 `/tmp` 默认值、产物外测试 fixture 不受影响和链接绕过失败关闭。上述证据用于证明正式发布只登记一个用户窗口,登录后同一 WebView 渲染首页、项目组和项目开发页,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,旧窗口切换 command 仅保留兼容、不进入用户主流程。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 - `npm run agc:config`:提供终端交互式配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导更新 LLM 主配置时必须保留已有 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。`agc:test:chat` 找不到有效配置时,只在 stdin / stdout 均为 TTY 的人工运行中询问是否进入该向导;非 TTY 直接失败并提示先运行 `npm run agc:config`,不得等待输入或写入默认密钥。 @@ -664,7 +664,7 @@ game-project/ - `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent`、`--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。 - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 -- “打开 Godot 项目”只接受根目录存在普通文件 `project.godot` 的现有工程。首次打开只在该根目录初始化 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;后续项目上下文、文件工具、命令 cwd 和 Agent Runtime 都继续使用用户选择的 Godot 根目录,不回退到默认 Web 项目目录体系。已存在 `.agent/manifest.json` 时只读取并复用,不覆盖现有元数据。 +- “打开项目”自动识别 Godot:先检查用户所选工作区根的普通文件 `project.godot`;根未命中时只检查一层直接子目录。根命中优先,子目录只有唯一命中才通过;多个子工程时必须在任何 `.agent` 写入前明确报错,要求用户改选具体工程,不能猜测。候选目录与 `project.godot` 都拒绝符号链接和 Windows reparse point,二层及更深目录不递归。首次导入只在用户所选工作区根初始化 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`,manifest 以 `.` 或单段直接子目录名记录可移植的 `godotProjectRoot`;Agent DB 的 `project.import` 同步记录该相对根。后续项目上下文、文件工具、Session、Runner 和 Agent Runtime 始终使用工作区根;只有未来 Godot 专属启动、构建、测试或导出动作才以经过校验的 Godot 根作为相对 cwd。已有 `.agent/manifest.json` 缺少或记录错误的 `godotProjectRoot` 时,仅在 Godot 打开边界按唯一文件布局校准;歧义时不改写 manifest。 - v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 - 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 每 6 轮形成一个上下文压缩窗口:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束,response 为空时进入独立最终回复生成。窗口边界会压缩 observation;有新的独立观察时在同一 run 继续下一窗口,最近窗口重复无进展时才以 `budget-exhausted / loop-budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.file.delete` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`file.delete`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`file.delete` 只删除项目内普通文件,默认确认且不能访问 `.agent/**`;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 - 2026-07-15 V1.21 修正:上一条中“每 6 轮形成上下文压缩窗口并压缩 observation”的描述已经失效;6 轮边界现在只形成进度 checkpoint 并执行停滞检测,真正的持久摘要只在 token 估算超过自动阈值或开发者显式执行 `/compact` 时发生。 @@ -681,7 +681,7 @@ game-project/ - 2026-07-10 补充:当前工具箱还开放 `agent.delegate`,审计记录类型为 `agent.runtime.agent.delegate`;该工具只把任务投递到目标 Agent 既有后台队列,不创建平行 runtime,不绕过目标 Agent 的锁和队列,也受 `agent.delegate` 项目权限策略保护。 - 2026-07-10 补充:当前 Runtime 命令和后台工具箱都开放 `agent.schedule_ready`,审计记录类型为 `agent.runtime.ready_task.scheduled`;该命令把 manifest ready task 批量投递到对应 Agent 的既有后台队列,默认需要确认,确认前不会启动下游 Agent。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 -- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。 +- 单窗口首页和项目组页不保留常驻路径输入;项目组只展示最近项目只读检查后的工作区绝对路径。打开 / 新建使用原生目录选择器,行尾菜单可显示已确认存在的项目目录,非法或相对路径不会调用系统文件管理器。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 @@ -787,13 +787,13 @@ game-project/ - 聊天输入 `/internals` 只列出项目内部真相源读取命令,覆盖 `.agent/manifest.json`、`.agent/run.latest.json`、`.agent/spec.md`、`.agent/findings.md`、`.agent/policy.json`、`.agent/project.index.json`、`.agent/agent.db` 和 `.agent/conversations/project.jsonl`;该命令不调用 Tauri 读写、不读取文件、不新增普通用户内部文件面板,真正读取仍由用户发送 `/read` 并走 `file.read` 权限流。 - 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。 - 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示底部专业 Agent 状态和悬浮详情,不提供单 Agent 对话入口、Session 控件或调度工具台。 -- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 +- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页以紧凑表格管理最近项目,顶部提供搜索、打开项目、新建项目,显示目录和移除最近记录进入行尾菜单;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 - 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 - 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 - 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。 -- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 -- 最近项目列表只来自本机 WebView storage,可移除单项或清空;这些操作不触碰项目目录,也不触发窗口切换。项目组页会只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则显示最近 run 的 status / stopReason;缺失、非目录、未初始化或检查失败的条目禁用打开,用户可移除后重新选择。刷新最近项目只重新执行这组只读检查,不改项目目录或最近项目列表;刷新未完成时按钮显示“刷新中”并禁用,最近项目条目显示“检查中”且临时禁用打开 / 显示操作,避免重复触发并让用户知道检查仍在进行;“显示”只通过系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,缺失、非目录或检查失败时禁用,不初始化项目、不写最近项目。 -- 项目组页“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时留在当前页提示,不自动创建目录。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。 +- 首页发送以及项目组打开 / 新建都通过绑定当前 `client` 的 Tauri 非阻塞原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 +- 最近项目列表只来自本机 WebView storage,可逐行移除;该操作不触碰项目目录,也不触发窗口切换。项目组页自动只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则把 status / stopReason 转成面向用户的状态。缺失、非目录、manifest 损坏或检查失败的条目禁用打开;目录仍存在时可从行尾菜单显示,检查中临时禁用打开 / 显示。页面不恢复清空或手动刷新按钮,本地搜索也不改项目列表。 +- 项目组页“打开”直接进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;发现根目录或一层子目录中的唯一 Godot 工程时允许导入并进入项目。路径不存在、不是文件夹、普通未初始化目录或 Godot 歧义时留在当前页提示,且不写 `.agent`。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。 - 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。 - 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。 - 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。 @@ -1065,6 +1065,7 @@ game-project/ - Windows 原子文件事务在 rename 安装成功后必须立即释放临时文件句柄;恢复扫描遇到仍被活跃 writer 独占的临时文件时保留该文件并继续扫描已提交账本,不能让单个 `ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION` 阻断整个恢复。新建目录与安装关键 sidecar 后仍按既有平台能力同步文件和目录,不能把 Windows 目录 `sync_all` 失败误判为业务提交失败。 - `.agent/project.lock` 的 `create_new` 在 Windows 目标存在或处于 delete-pending 竞争时,可能返回 `ACCESS_DENIED(5)`、sharing violation(32) 或 lock violation(33),这些结果统一投影为“项目正在被其他写操作占用”并进入既有有界等待;其他权限错误继续失败关闭。Runtime 测试若在终态后立即二次恢复,必须同时等待 `status/phase` 终态和 Agent execution lane 释放,不能只观察 state JSON。 - Windows 子进程启动把 `npm.cmd` 解析为当前 Node 与 `npm-cli.js` 的显式 argv,保留 CRLF/ANSI/ConPTY 处理和 Job Object 生命周期;项目验证使用隔离 Cargo target wrapper,避免开发 GUI 或旧 runner 持有测试需要替换的 EXE。Agent DB 打开继续允许同进程读写共享并修复唯一 JSONL 残尾,不能用默认独占句柄破坏并发读取。 +- 发布 GUI 内的 Codex CLI 可用性探测(包括候选版本检查和 `app-server --help`)必须与实际 Codex、MCP、Runner 和项目命令一样使用 `CREATE_NO_WINDOW`;读取对话或配置状态时即使连续探测多个候选,也不能创建或闪烁控制台窗口。 - Node ESM 脚本必须用 `fileURLToPath()` 把 `import.meta.url` 转为 Windows 本地路径,禁止直接把 URL pathname 交给 `path.resolve()`;真实 agent-run smoke 的浏览器探测覆盖 Windows Chrome/Edge 固定安装位置。开发态 smoke 在旧安装版持有默认 AppData GUI owner 时使用独立 `--config-dir`,不得终止用户现有客户端。 - 2026-08-12 计划拒绝恢复:结构化 `runtime.plan_update` 被 Runtime 拒绝后,下一轮 Provider 请求按请求级目录收窄到实际项目 mutation 与 `respond_to_user`(已进入协作编排的 Supervisor 保留 `agent.delegate / agent.run_status`),并明确禁止再次规划、读取、搜索或验证;后续已有真实 mutation observation 后解除临时目录,不改变持久 executable policy。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1ad4de044..16de6919b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -591,6 +591,8 @@ Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者 `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 +`Genarrative-Web-Build` 在运行根 Vitest 前必须分别按根 `package-lock.json` 和 `apps/ai-game-creator-shell/package-lock.json` 执行干净依赖安装,即先执行根 `npm ci`,再执行 `npm ci --prefix apps/ai-game-creator-shell`。根 Vitest 会收集 AI 游戏创作壳的测试,如果只安装根 lockfile,收集阶段会因缺少 `@tauri-apps/api`、`@tauri-apps/plugin-http` 等 Tauri guest 依赖而失败。这些依赖属于 AI 游戏创作壳的独立安装边界,不得为了让 Jenkins 通过而将它们加入根 H5 `package.json`;排查同类报错时先核对两份 lockfile 是否都已安装。 + `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 `Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会在 current 切换前停止部署,清理 staging 并退出本次打开的维护模式;current 切换后的 Pingora 重启、worker 重启、controller 启动或 readiness 失败仍保留维护模式,避免暴露半发布版本;失败不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、Pingora manifest / 二进制漂移、`--require-pingora-gateway` 缺少 Pingora、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会在 current 切换前失败并退出本次打开的维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现时拒绝覆盖 / 合并,以及 current 切换后的 readiness 失败会保留维护模式。Pingora 影子网关在 `Genarrative-Api-Build`、`Genarrative-Api-Deploy` 和 `Genarrative-Full-Build-And-Deploy` 中默认随 release 构建、归档、复制并用 `--require-pingora-gateway` 硬校验;只有显式取消 `INCLUDE_PINGORA_GATEWAY` 时才允许 API release 不带 `pingora-gateway` / `pingora-gateway.sha256`。本地 CLI 仍保留显式 `npm run build:production-release -- --component api-server --include-pingora-gateway`,用于在需要 Pingora 的手工发布包里登记 manifest 和 checksum;Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 @@ -672,6 +674,8 @@ npm run container:down 容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 + +多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。 隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 53943f418..671196db6 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -63,7 +63,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。 -SFX V2 上线前已经存在的 SpacetimeDB 定价快照可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置或本地 override 补入缺失的 `eleven_text_to_sound_v2` 条目,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。 +SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置补入缺失的 `eleven_text_to_sound_v2` 条目,再执行完整配置校验,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。 ## 运行时身份首次授权 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 403a65806..02acb252b 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -17,7 +17,7 @@ - `生成角色动作` - `快速编辑` -`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。 +`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部改造或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。 `快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。单个拆分 `icon` 不支持快速编辑,完整 `icon-spritesheet` 与 `icon-spec` 图标规范仍按普通图片支持快速编辑。 ## 统一布局 @@ -42,7 +42,7 @@ 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本、Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 CSS。 11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;可选参考图字段为 `referenceId`,只允许当前 owner 的项目资源 ID 或素材 ID。后端固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 执行图片生成。inline 路径校验业务字段和 `referenceId` 后,补全 `ExtraParam` 与最终 prompt 并交给共享图片生成执行器;queue 路径在预校验后按 `gpt-image-2 / 2K` 运行时定价冻结价格,再以独立 `editor_icon_spec_generation` job kind 入队原始业务载荷。worker 使用入队价格和当前 claim attempt 的计费上下文,重新解析载荷、校验当前 owner 与引用事实,再补全 `ExtraParam` 并调用同一共享图片生成执行器,避免排队期间状态变化产生 TOCTOU。 -12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId` 或 `sourceAssetId` 始终作为 `/api/editor/images/edits` 的 `sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`。从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图也继续作为辅助引用,不得替代主来源身份。 +12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId` 或 `sourceAssetId` 始终作为 `/api/editor/images/edits` 的 `sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`。`image.edit` 是原位替换记录,不提供“改造”恢复入口;用户需要继续修改时重新打开当前图片的快速编辑。 13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 ## 参数交互 @@ -72,7 +72,7 @@ - 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。 - 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。 - 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data`,`gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。 -- 图片改造入口也要保持同样约束:恢复历史参数时优先使用 `generationInputs` 中保存的模型、比例和尺寸,失败回退到当前关联参数后再映射为实际 `size`,并一并回传到编辑请求;`canvasCompletion` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数。 +- 生成类产物的“改造”按原 `action` 恢复对应 generation dialog,并沿用该 action 的参数校验、生成请求、占位和完成回填合同;它生成新产物,不转入 `/api/editor/images/edits` 原位替换链路。`image.edit` 只属于已知配方,不属于可改造 action。 - 不再在底部常驻展开全部可选项。 ## 泥点显示 @@ -81,7 +81,7 @@ - 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。 - 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。 - 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost`:`/api/editor/icon-specs/generations`、`/api/editor/images/generations`、`/api/editor/images/edits`、`/api/editor/icon-spritesheets/generations`、`/api/editor/ui-designs/assets/extractions`、`/api/editor/videos/generations`、`/api/editor/character-animations/generations`、`/api/editor/audios/sound-effects/generations`、`/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。 -- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。 +- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、图片类改造、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。 - 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。 - 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。 - 进入外部生成队列的任务在入队时冻结后端计算出的价格;worker 的预扣、退款、响应 `priceMudPoints` 和资产 `generationCostMudPoints` 必须使用同一入队价格,后台修改模型定价只影响之后入队的任务。 @@ -99,11 +99,11 @@ - 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。 - 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。 - 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。 -- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。 +- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,不能默认全部回退为图片图标。快速编辑不创建生成占位,因此不定义快速编辑占位图标。 - 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;所有共用规格都不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。provider 请求尺寸可因接口合法值不同,但不得泄漏为正常完成的画布尺寸;回图更大时只允许缩小和轻微裁切,回图低于目标时保留实际像素并告警,禁止放大伪造所选档位;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 -- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 +- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、按原 action 执行的改造、去背景和角色动作等会产出新图层的入口。 - 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。 - 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。 @@ -113,10 +113,10 @@ - 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。 - 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。 - 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。 -- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,提交层也必须拒绝绕过入口的调用。 +- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;改造、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图;视频、单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,程序化打开和提交层也必须拒绝绕过入口的调用。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐和角色动作等可改造生成 action;它按原 `action` 恢复对应 generation dialog 并生成新产物。`image.edit` 是图片快速编辑的原位替换配方,只属于已知 action,不属于可改造 action;其结果继续通过快速编辑修改,不恢复成 generation dialog。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。普通快速编辑不得提交未展示的隐藏参考图。普通视频生成继续按现有 V2 合同保存并恢复 `references[id="videoReference"]` 与 `webSearchEnabled`,该合同不表示支持视频快速编辑。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 @@ -125,7 +125,7 @@ - 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 - 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 -- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色改造遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 - 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,该情况不会进入拆分,因此与 `sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。 @@ -194,12 +194,12 @@ - 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图已登记业务 ID 作为 `sourceReferenceId`,红框序号标注图作为内部辅助 `referenceImageSrcs`,不展示额外参考图条。单个拆分图标不展示快速编辑入口,完整图标图集与图标规范仍可快速编辑。 - 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 - 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。 -- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图,以及从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图,均作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。 +- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。`image.edit` 不通过“改造”恢复历史附加引用。 - 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。 - 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。 - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 -- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 +- 点击角色图只选中图层并显示工具栏,不自动弹出改造、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 - 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化。正常透明化成功时复用图标素材拆分流程,把透明 spritesheet 图集和拆分成功的素材放到画布;透明背景处理最终失败时只把 provider 原图放到画布,不继续拆分,并显示通用 warning。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer new file mode 100644 index 000000000..0808dc5ed --- /dev/null +++ b/jenkins/Jenkinsfile.preview-deployer @@ -0,0 +1,152 @@ +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + timeout(time: 120, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '50')) + } + + environment { + GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments' + GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82' + } + + parameters { + choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL'], description: '部署、查询状态或卸载分支预览容器') + string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支;DEPLOY 必填,同一分支稳定映射到同一个预览实例') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选;必须是 SOURCE_BRANCH 历史中的 commit') + string(name: 'DEPLOYMENT_ID', defaultValue: '', description: '可选;留空时由 SOURCE_BRANCH 稳定生成,查询和卸载可直接传部署 ID') + } + + stages { + stage('Checkout Controller') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: '*/master']], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[ + url: env.GIT_REMOTE_URL, + credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, + refspec: '+refs/heads/master:refs/remotes/origin/master', + ]], + ]) + } + } + + stage('Validate Request') { + steps { + sh ''' + set -euo pipefail + ACTION="${ACTION:-}" + SOURCE_BRANCH="${SOURCE_BRANCH:-}" + COMMIT_HASH="${COMMIT_HASH:-}" + DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" + case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac + if [ -n "${SOURCE_BRANCH}" ]; then + git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null + fi + if [ -n "${COMMIT_HASH}" ] && ! printf '%s' "${COMMIT_HASH}" | grep -Eq '^[0-9a-fA-F]{7,40}$'; then + echo 'COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。' >&2 + exit 1 + fi + if [ -n "${DEPLOYMENT_ID}" ] && ! printf '%s' "${DEPLOYMENT_ID}" | grep -Eq '^preview-[0-9a-f]{16}$'; then + echo 'DEPLOYMENT_ID 格式非法。' >&2 + exit 1 + fi + ''' + } + } + + stage('Checkout Requested Source') { + when { + expression { params.ACTION == 'DEPLOY' } + } + steps { + dir('source') { + script { + def remoteConfig = [ + url: env.GIT_REMOTE_URL, + credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, + refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", + ] + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [remoteConfig], + ]) + } + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { + sh ''' + set -euo pipefail + SOURCE_BRANCH="${SOURCE_BRANCH:-}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ + GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh" + ''' + } + } + } + } + + stage('Execute Preview Action') { + steps { + sh ''' + set -euo pipefail + chmod +x scripts/jenkins-preview-deployer.sh + SOURCE_DIR="${WORKSPACE}/source" \ + RESULT_FILE="${WORKSPACE}/preview-result.json" \ + DESCRIPTION_FILE="${WORKSPACE}/.jenkins-preview-description" \ + scripts/jenkins-preview-deployer.sh + ''' + script { + if (fileExists('.jenkins-preview-description')) { + currentBuild.description = readFile('.jenkins-preview-description').trim() + } + } + } + } + } + + post { + always { + script { + if (!fileExists('preview-result.json')) { + writeFile file: 'preview-result.json', text: groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([ + schemaVersion: 1, + action: params.ACTION ?: 'UNKNOWN', + deploymentId: params.DEPLOYMENT_ID ?: null, + projectName: params.DEPLOYMENT_ID ? "genarrative-${params.DEPLOYMENT_ID}" : null, + branch: params.SOURCE_BRANCH ?: null, + requestedCommit: params.COMMIT_HASH ?: null, + resolvedCommit: null, + status: 'failed', + health: 'unknown', + webUrl: null, + message: 'Jenkins 在生成部署结果前失败,请查看构建日志。', + updatedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX"), + ])) + '\n' + } + } + archiveArtifacts artifacts: 'preview-result.json', allowEmptyArchive: false, fingerprint: true + } + } +} diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 64b032762..de098683f 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -22,7 +22,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否按根 lockfile 与 AGC 独立 lockfile 执行 npm ci') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file', description: '必填:Stdb module 构建与发布共用的 Jenkins Secret File 凭据 ID') booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含') diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 200483d69..1413d6113 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -22,7 +22,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') - booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否执行 npm ci') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否按根 lockfile 与 AGC 独立 lockfile 执行 npm ci') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') @@ -91,6 +91,7 @@ pipeline { script { if (params.RUN_NPM_CI) { sh 'bash -lc "npm ci"' + sh 'bash -lc "npm ci --prefix apps/ai-game-creator-shell"' } } sh ''' diff --git a/jenkins/preview-deployer-job-config.xml b/jenkins/preview-deployer-job-config.xml new file mode 100644 index 000000000..a0cbd4247 --- /dev/null +++ b/jenkins/preview-deployer-job-config.xml @@ -0,0 +1,73 @@ + + + + 内网分支容器预览,仅由 Preview Deployer 控制面触发。 + false + + + + + ACTION + 部署、查询状态或卸载分支预览容器 + + + DEPLOY + STATUS + UNINSTALL + + + + + SOURCE_BRANCH + 源码分支;DEPLOY 和 UNINSTALL 必填 + + true + + + COMMIT_HASH + 可选;必须是 SOURCE_BRANCH 历史中的 commit + + true + + + DEPLOYMENT_ID + 预览实例稳定 ID + + true + + + + + + + 2 + + + ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git + genarrative-local-gitea-ssh + +refs/heads/master:refs/remotes/origin/master + + + + + */master + + + false + + + + true + true + + 1 + true + + + + jenkins/Jenkinsfile.preview-deployer + false + + + false + diff --git a/package.json b/package.json index d57f551fe..8e6902e9d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", + "preview-deployer:server": "cargo run -p preview-deployer-server --manifest-path server-rs/Cargo.toml", + "preview-deployer:web:dev": "npm --prefix apps/preview-deployer-web run dev", + "preview-deployer:web:typecheck": "npm --prefix apps/preview-deployer-web run typecheck", + "preview-deployer:web:test": "npm --prefix apps/preview-deployer-web run test", + "preview-deployer:web:build": "npm --prefix apps/preview-deployer-web run build", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "otel:debug": "node scripts/run-otelcol.mjs debug", "otel:rider": "node scripts/run-otelcol.mjs rider", @@ -46,6 +51,7 @@ "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", + "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", "check:database-backup": "node scripts/check-database-backup-to-oss.mjs", "check:production-health-patrol": "node scripts/check-production-health-patrol.mjs", @@ -93,7 +99,7 @@ "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index fb7d03553..7d3d353c1 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -474,6 +474,7 @@ describe('AI 游戏创作 App 共享契约', () => { ); expect(manifest.projectId).toBe('project-1'); expect(manifest.name).toBe('像素动作原型'); + expect(manifest.godotProjectRoot).toBeUndefined(); expect(manifest.assets).toEqual([]); expect(manifest.tasks.map((task) => task.id)).toEqual([ 'design-director', @@ -544,6 +545,18 @@ describe('AI 游戏创作 App 共享契约', () => { ]); }); + it('keeps the workspace-relative Godot project root in manifest JSON', () => { + const manifest: GameCreationAppManifest = { + ...createGameCreationAppManifest('project-1', 'Godot 项目'), + godotProjectRoot: 'game-source', + }; + + expect(JSON.parse(JSON.stringify(manifest))).toMatchObject({ + projectId: 'project-1', + godotProjectRoot: 'game-source', + }); + }); + it('selects ready tasks from dependency status', () => { const manifest = createGameCreationAppManifest('project-1', '像素动作原型'); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index ffc84a8f0..0cc22275b 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -580,6 +580,7 @@ export interface GameCreationAppManifest { preview?: GameCreationAppPreviewState | null; commandRuns?: GameCreationAppCommandRunState[]; versions?: GameIterationVersion[]; + godotProjectRoot?: string | null; } export interface GameCreationAgentToolCallTrace { diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 9bcc538b7..7e4cd1082 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -60,6 +60,12 @@ const aiGameCreatorShellTauriConfig = JSON.parse( 'utf8', ), ); +const aiGameCreatorGameChatTauriConfig = JSON.parse( + fs.readFileSync( + 'apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json', + 'utf8', + ), +); const aiGameCreatorShellTauriSource = readSourceTree( 'apps/ai-game-creator-shell/src-tauri/src', '.rs', @@ -2351,6 +2357,32 @@ function assertAiGameCreatorShellUserDevBoundary() { 'AI game creator release shell must register only the client window', ); } + const clientWindow = windows[0]; + if ( + clientWindow?.width !== 1280 || + clientWindow?.height !== 800 || + clientWindow?.minWidth !== 1280 || + clientWindow?.minHeight !== 720 + ) { + throw new Error( + 'AI game creator release shell must default to 1280x800 and stay at least 1280x720', + ); + } + const gameChatWindows = aiGameCreatorGameChatTauriConfig.app?.windows ?? []; + const gameChatClientWindow = gameChatWindows[0]; + if ( + gameChatWindows.length !== 1 || + gameChatClientWindow?.label !== 'client' || + gameChatClientWindow?.url !== 'index.html' || + gameChatClientWindow?.width !== 1280 || + gameChatClientWindow?.height !== 800 || + gameChatClientWindow?.minWidth !== 1280 || + gameChatClientWindow?.minHeight !== 720 + ) { + throw new Error( + 'AI game creator game-chat shell must default to 1280x800 and stay at least 1280x720', + ); + } const releaseCsp = aiGameCreatorShellTauriConfig.app?.security?.csp ?? ''; const devCsp = aiGameCreatorShellTauriConfig.app?.security?.devCsp ?? ''; const releaseFrameSrc = diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs new file mode 100644 index 000000000..5cee19342 --- /dev/null +++ b/scripts/check-preview-deployer.mjs @@ -0,0 +1,248 @@ +import { readFileSync } from 'node:fs'; + +const jenkinsfile = readFileSync( + 'jenkins/Jenkinsfile.preview-deployer', + 'utf8', +); +const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8'); +const resultWriter = readFileSync( + 'scripts/preview-deployment-status.mjs', + 'utf8', +); +const compose = readFileSync( + 'deploy/container/docker-compose.loadtest.yml', + 'utf8', +); +const webConfig = readFileSync( + 'apps/preview-deployer-web/vite.config.ts', + 'utf8', +); +const systemd = readFileSync( + 'deploy/systemd/genarrative-preview-deployer.service', + 'utf8', +); +const environmentExample = readFileSync( + 'deploy/env/preview-deployer.env.example', + 'utf8', +); +const server = readFileSync( + 'server-rs/crates/preview-deployer-server/src/lib.rs', + 'utf8', +); +const nginx = readFileSync( + 'deploy/nginx/genarrative-preview-deployer-lan.conf', + 'utf8', +); +const jobConfig = readFileSync( + 'jenkins/preview-deployer-job-config.xml', + 'utf8', +); + +const failures = []; +assertIncludes( + jenkinsfile, + "choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']", + 'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。', +); +assertIncludes( + jenkinsfile, + "string(name: 'SOURCE_BRANCH'", + 'Jenkins 必须暴露 SOURCE_BRANCH。', +); +assertIncludes( + jenkinsfile, + "string(name: 'COMMIT_HASH'", + 'Jenkins 必须暴露 COMMIT_HASH。', +); +assertIncludes( + jenkinsfile, + "string(name: 'DEPLOYMENT_ID'", + 'Jenkins 必须暴露 DEPLOYMENT_ID。', +); +assertIncludes( + jenkinsfile, + "archiveArtifacts artifacts: 'preview-result.json'", + 'Jenkins 必须归档 preview-result.json。', +); +assertIncludes( + jenkinsfile, + "writeFile file: 'preview-result.json'", + 'Jenkins 失败路径也必须生成 preview-result.json。', +); +assertIncludes( + jenkinsfile, + 'disableConcurrentBuilds()', + 'Jenkins Job 必须禁止并发构建。', +); +assertIncludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH:-}"', + 'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。', +); +assertIncludes( + jenkinsfile, + 'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"', + 'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。', +); +assertExcludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH}"', + 'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。', +); +assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。'); +assertIncludes( + deployer, + 'git check-ref-format --branch', + '部署脚本必须校验分支格式。', +); +assertIncludes( + deployer, + 'genarrative-preview-[0-9a-f]{16}', + '卸载必须限制到预览 compose project 白名单。', +); +assertIncludes( + deployer, + 'allocate_port webPort 8400 8499', + 'Web 端口必须从 8400-8499 分配。', +); +assertIncludes( + deployer, + 'ports: !reset []', + '预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。', +); +assertIncludes( + deployer, + 'spacetimedb:\n mem_limit: 2g\n ports: !reset []', + '预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。', +); +assertIncludes( + deployer, + 'external-generation-worker:\n build:', + '预览 compose override 必须配置外部生成 worker。', +); +assertIncludes( + deployer, + 'restart: on-failure', + '预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。', +); +assertIncludes( + deployer, + '^preview-[0-9a-f]{16}$', + '部署 ID 必须固定为 preview- 加 16 位摘要。', +); +assertIncludes( + deployer, + 'state.active === true && String(state[key]) === expected', + '只有活动实例才能占用端口租约。', +); +assertIncludes( + deployer, + '--yes=remote,migrate,break-clients', + 'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。', +); +assertExcludes( + deployer, + '--delete-data', + '预览部署不得使用 SpacetimeDB 删除数据参数。', +); +assertIncludes( + jenkinsfile, + "GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'", + 'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。', +); +assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。'); +assertIncludes( + systemd, + 'User=jenkins', + '控制服务必须使用 Jenkins 运行用户以访问受控构建状态。', +); +assertIncludes( + nginx, + 'location ^~ /build/', + '内网 Nginx 必须提供 /build/ 路由。', +); +assertIncludes( + server, + '.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))', + '控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。', +); +assertIncludes( + server, + 'FAILED_RECORD_TTL_SECS', + '控制服务必须清理过期且不可卸载的失败/取消记录。', +); +assertIncludes( + server, + 'STOPPED_RECORD_TTL_SECS', + '控制服务必须为已卸载记录配置有限审计保留期。', +); +assertIncludes( + environmentExample, + 'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/', + '构建详情必须使用局域网 Jenkins 地址而非 loopback。', +); +assertIncludes( + jobConfig, + 'jenkins/Jenkinsfile.preview-deployer', + 'Jenkins Job 必须从受保护 master 读取固定流水线。', +); +assertIncludes( + jobConfig, + '', + 'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。', +); +assertIncludes( + nginx, + 'location ^~ /api/preview-deployer/', + '内网 Nginx 必须代理预览控制 API。', +); +assertIncludes( + resultWriter, + 'schemaVersion: 1', + '结果文件必须带版本化 schema。', +); +assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。'); +assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。'); +assertIncludes( + resultWriter, + 'resolvedCommit', + '结果文件必须提供 resolvedCommit。', +); +assertIncludes( + resultWriter, + 'status: publicStatus(phase)', + '结果文件必须提供小写 status 契约。', +); +assertIncludes( + resultWriter, + 'health: publicHealth(healthStatus)', + '结果文件必须提供小写 health 契约。', +); +assertIncludes( + compose, + 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]', + 'Nginx 容器健康检查必须验证 Web 首页。', +); +assertExcludes( + compose, + 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]', + 'Nginx 容器健康检查不能访问公开拒绝的 /healthz。', +); + +if (failures.length > 0) { + for (const failure of failures) + console.error(`[check:preview-deployer] ${failure}`); + process.exit(1); +} + +console.log( + '[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。', +); + +function assertIncludes(content, expected, message) { + if (!content.includes(expected)) failures.push(message); +} + +function assertExcludes(content, expected, message) { + if (content.includes(expected)) failures.push(message); +} diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index ee0975dc9..8caf8b132 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -7376,6 +7376,38 @@ if ((fullPipelineMaintenanceHoldCalls?.length ?? 0) !== 2) { ); } +const webBuildContent = readFileSync( + 'jenkins/Jenkinsfile.production-web-build', + 'utf8', +); +const webBuildStageOffset = webBuildContent.indexOf("stage('Build Web')"); +const webArchiveStageOffset = webBuildContent.indexOf( + "stage('Archive')", + webBuildStageOffset, +); +const webBuildStageContent = + webBuildStageOffset >= 0 && webArchiveStageOffset > webBuildStageOffset + ? webBuildContent.slice(webBuildStageOffset, webArchiveStageOffset) + : ''; +const webNpmCiBlock = `if (params.RUN_NPM_CI) { + sh 'bash -lc "npm ci"' + sh 'bash -lc "npm ci --prefix apps/ai-game-creator-shell"' + }`; +const webNpmCiBlockOffset = webBuildStageContent.indexOf(webNpmCiBlock); +const webTestOffset = webBuildStageContent.indexOf('npm run test'); +const webNpmCiCalls = webBuildStageContent.match(/\bnpm ci(?:\s|["'])/gu); +if ( + webNpmCiBlockOffset < 0 || + webTestOffset < 0 || + webNpmCiBlockOffset >= webTestOffset || + (webNpmCiCalls?.length ?? 0) !== 2 +) { + failed = true; + console.error( + '[check:production-ops] Web Build 必须在 RUN_NPM_CI 条件块内依次安装根 lockfile 与 apps/ai-game-creator-shell 独立 lockfile,并在 npm run test 前完成;RUN_NPM_CI=false 时两次安装必须一起跳过。', + ); +} + const exitMaintenanceStageOffset = fullPipelineContent.indexOf( "stage('Exit Maintenance')", ); diff --git a/scripts/deploy/preview-deployer-install.sh b/scripts/deploy/preview-deployer-install.sh new file mode 100644 index 000000000..346588b1c --- /dev/null +++ b/scripts/deploy/preview-deployer-install.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +INSTALL_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_INSTALL_ROOT:-/opt/genarrative/preview-deployer}" +STATE_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_STATE_ROOT:-/var/lib/genarrative/preview-deployer}" +ENV_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_ENV_FILE:-/etc/genarrative/preview-deployer.env}" +UNIT_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_UNIT_FILE:-/etc/systemd/system/genarrative-preview-deployer.service}" + +if [[ "${EUID}" -ne 0 ]]; then + echo "[preview-deployer-install] 必须以 root 执行安装。" >&2 + exit 1 +fi +if [[ ! -f "${ENV_FILE}" ]]; then + echo "[preview-deployer-install] 缺少私密配置 ${ENV_FILE};请从 deploy/env/preview-deployer.env.example 创建并填入 Jenkins Token 和控制面口令。" >&2 + exit 1 +fi + +cd "${ROOT_DIR}" +PREVIEW_DEPLOYER_WEB_BASE=/build/ npm run preview-deployer:web:build +cargo build -p preview-deployer-server --release --manifest-path server-rs/Cargo.toml + +install -d -o jenkins -g jenkins -m 0750 "${INSTALL_ROOT}" "${INSTALL_ROOT}/web" +install -d -o jenkins -g jenkins -m 0700 "${STATE_ROOT}" +install -o root -g root -m 0755 \ + server-rs/target/release/preview-deployer-server \ + "${INSTALL_ROOT}/preview-deployer-server" +find "${INSTALL_ROOT}/web" -mindepth 1 -delete +cp -a apps/preview-deployer-web/dist/. "${INSTALL_ROOT}/web/" +chown -R root:root "${INSTALL_ROOT}/web" +find "${INSTALL_ROOT}/web" -type d -exec chmod 0755 {} + +find "${INSTALL_ROOT}/web" -type f -exec chmod 0644 {} + +install -o root -g root -m 0644 \ + deploy/systemd/genarrative-preview-deployer.service "${UNIT_FILE}" + +systemctl daemon-reload +systemctl enable --now genarrative-preview-deployer.service +curl --fail --silent --show-error --max-time 5 \ + --header 'Host: 192.168.35.82' http://127.0.0.1:8410/healthz >/dev/null +echo "[preview-deployer-install] 控制服务已安装;请将 deploy/nginx/genarrative-preview-deployer-lan.conf 嵌入内网默认 server 后执行 nginx -t && systemctl reload nginx。" diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh new file mode 100644 index 000000000..0171628b8 --- /dev/null +++ b/scripts/jenkins-preview-deployer.sh @@ -0,0 +1,480 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ACTION="${ACTION:-STATUS}" +SOURCE_BRANCH="${SOURCE_BRANCH:-}" +COMMIT_HASH="${COMMIT_HASH:-}" +DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" +SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}" +RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}" +DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}" +STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}" +WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}" +LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}" + +STATE_DIR="" +STATE_FILE="" +PROJECT_NAME="" +SCRIPT_ROOT="" +SCRIPT_FAILED=1 + +fail() { + echo "[preview-deployer] $*" >&2 + exit 1 +} + +validate_request() { + case "${ACTION}" in + DEPLOY|UNINSTALL|STATUS) ;; + *) fail "ACTION 只支持 DEPLOY、UNINSTALL 或 STATUS: ${ACTION}" ;; + esac + + if [[ -n "${SOURCE_BRANCH}" ]]; then + [[ "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]] || fail "SOURCE_BRANCH 包含不允许的字符。" + [[ "${SOURCE_BRANCH}" != /* && "${SOURCE_BRANCH}" != */ && "${SOURCE_BRANCH}" != *..* ]] || \ + fail "SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号。" + git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null 2>&1 || fail "SOURCE_BRANCH 不是合法 Git 分支名。" + fi + + if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + fail "COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。" + fi + + if [[ "${ACTION}" == "DEPLOY" && -z "${SOURCE_BRANCH}" ]]; then + fail "DEPLOY 必须填写 SOURCE_BRANCH。" + fi + if [[ "${ACTION}" == "UNINSTALL" && -z "${SOURCE_BRANCH}" ]]; then + fail "UNINSTALL 必须同时填写受控 SOURCE_BRANCH,以验证部署所有权。" + fi + if [[ "${ACTION}" != "DEPLOY" && -z "${SOURCE_BRANCH}" && -z "${DEPLOYMENT_ID}" ]]; then + fail "UNINSTALL/STATUS 至少需要 SOURCE_BRANCH 或 DEPLOYMENT_ID。" + fi + + if [[ -n "${DEPLOYMENT_ID}" && ! "${DEPLOYMENT_ID}" =~ ^preview-[0-9a-f]{16}$ ]]; then + fail "DEPLOYMENT_ID 必须符合 preview- 加 16 位小写十六进制摘要。" + fi +} + +derive_deployment_id() { + local digest + [[ -n "${SOURCE_BRANCH}" ]] || return 0 + digest="$(printf '%s' "${SOURCE_BRANCH}" | sha256sum)" + digest="${digest%% *}" + printf 'preview-%s\n' "${digest:0:16}" +} + +resolve_identity() { + local derived_id="" + derived_id="$(derive_deployment_id)" + if [[ -z "${DEPLOYMENT_ID}" ]]; then + DEPLOYMENT_ID="${derived_id}" + elif [[ -n "${derived_id}" && "${DEPLOYMENT_ID}" != "${derived_id}" ]]; then + fail "DEPLOYMENT_ID 与 SOURCE_BRANCH 的稳定映射不一致,预期 ${derived_id}。" + fi + + [[ -n "${DEPLOYMENT_ID}" ]] || fail "无法确定 DEPLOYMENT_ID。" + PROJECT_NAME="genarrative-${DEPLOYMENT_ID}" + STATE_DIR="${STATE_ROOT}/${DEPLOYMENT_ID}" + STATE_FILE="${STATE_DIR}/state.json" +} + +ensure_safe_state_root() { + [[ "${STATE_ROOT}" == /* && "${STATE_ROOT}" != "/" ]] || fail "状态目录必须是非根目录绝对路径。" + if [[ -L "${STATE_ROOT}" ]]; then + fail "状态目录不能是符号链接: ${STATE_ROOT}" + fi + install -d -m 0700 "${STATE_ROOT}" + if [[ -e "${STATE_DIR}" && ( -L "${STATE_DIR}" || ! -d "${STATE_DIR}" ) ]]; then + fail "部署状态路径必须是普通目录且不能是符号链接: ${STATE_DIR}" + fi + install -d -m 0700 "${STATE_DIR}" +} + +state_value() { + local key="$1" + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 + node -e ' + const fs = require("node:fs"); + const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))[process.argv[2]]; + if (value !== undefined && value !== null) process.stdout.write(String(value)); + ' "${STATE_FILE}" "${key}" +} + +validate_existing_state() { + local saved_id saved_project saved_branch + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 + saved_id="$(state_value deploymentId)" + saved_project="$(state_value projectName)" + saved_branch="$(state_value sourceBranch)" + [[ "${saved_id}" == "${DEPLOYMENT_ID}" ]] || fail "状态文件 deploymentId 不匹配。" + [[ "${saved_project}" == "${PROJECT_NAME}" ]] || fail "状态文件 compose project 不匹配。" + if [[ -n "${SOURCE_BRANCH}" && "${saved_branch}" != "${SOURCE_BRANCH}" ]]; then + fail "部署实例属于分支 ${saved_branch},不能用 ${SOURCE_BRANCH} 操作。" + fi + if [[ -z "${SOURCE_BRANCH}" ]]; then + SOURCE_BRANCH="${saved_branch}" + fi +} + +write_state() { + local phase="$1" + local active="$2" + local source_commit="$3" + local web_port="$4" + local spacetime_port="$5" + local otlp_grpc_port="$6" + local otlp_http_port="$7" + local temporary_file="${STATE_FILE}.tmp.$$" + node - "${temporary_file}" "${DEPLOYMENT_ID}" "${PROJECT_NAME}" "${SOURCE_BRANCH}" \ + "${COMMIT_HASH}" "${source_commit}" "${phase}" "${active}" "${web_port}" "${spacetime_port}" \ + "${otlp_grpc_port}" "${otlp_http_port}" <<'NODE' +const fs = require('node:fs'); +const [file, deploymentId, projectName, sourceBranch, requestedCommit, sourceCommit, phase, active, + webPort, spacetimePort, otlpGrpcPort, otlpHttpPort] = process.argv.slice(2); +const number = (value) => value ? Number(value) : null; +fs.writeFileSync(file, `${JSON.stringify({ + schemaVersion: 1, + deploymentId, + projectName, + sourceBranch, + requestedCommit: requestedCommit || null, + sourceCommit: sourceCommit || null, + phase, + active: active === 'true', + webPort: number(webPort), + spacetimePort: number(spacetimePort), + otlpGrpcPort: number(otlpGrpcPort), + otlpHttpPort: number(otlpHttpPort), + updatedAt: new Date().toISOString(), +}, null, 2)}\n`, {mode: 0o600}); +NODE + mv -f "${temporary_file}" "${STATE_FILE}" + chmod 0600 "${STATE_FILE}" +} + +render_result() { + local phase="${1:-}" + local message="${2:-}" + local args=( + "${SCRIPT_ROOT}/preview-deployment-status.mjs" + --action "${ACTION}" + --deployment-id "${DEPLOYMENT_ID}" + --project-name "${PROJECT_NAME}" + --source-branch "${SOURCE_BRANCH}" + --state-file "${STATE_FILE}" + --output "${RESULT_FILE}" + --description-file "${DESCRIPTION_FILE}" + --web-host "$(resolve_web_host)" + ) + if [[ -n "${phase}" ]]; then + args+=(--phase "${phase}") + fi + if [[ -n "${message}" ]]; then + args+=(--message "${message}") + fi + node "${args[@]}" +} + +resolve_web_host() { + local route_source + if [[ -n "${WEB_HOST}" ]]; then + printf '%s\n' "${WEB_HOST}" + return + fi + route_source="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -n 1)" + printf '%s\n' "${route_source:-127.0.0.1}" +} + +on_exit() { + local exit_code=$? + if [[ "${SCRIPT_FAILED}" == "1" && -n "${STATE_FILE}" ]]; then + set +e + if [[ "${ACTION}" == "DEPLOY" && -f "${STATE_FILE}" ]]; then + write_state "FAILED" "false" "$(state_value sourceCommit)" \ + "$(state_value webPort)" "$(state_value spacetimePort)" \ + "$(state_value otlpGrpcPort)" "$(state_value otlpHttpPort)" + fi + render_result "FAILED" "预览部署执行失败,请查看 Jenkins 构建日志。" + fi + exit "${exit_code}" +} +trap on_exit EXIT + +port_used_by_other_state() { + local key="$1" + local port="$2" + node - "${STATE_ROOT}" "${DEPLOYMENT_ID}" "${key}" "${port}" <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const [root, currentId, key, expected] = process.argv.slice(2); +let used = false; +for (const entry of fs.readdirSync(root, {withFileTypes: true})) { + if (!entry.isDirectory() || entry.name === currentId) continue; + try { + const state = JSON.parse(fs.readFileSync(path.join(root, entry.name, 'state.json'), 'utf8')); + if (state.active === true && String(state[key]) === expected) used = true; + } catch {} +} +process.exit(used ? 0 : 1); +NODE +} + +port_is_listening() { + local port="$1" + ss -H -ltn "sport = :${port}" | grep -q . +} + +allocate_port() { + local key="$1" + local start="$2" + local end="$3" + local preferred="${4:-}" + local port + if [[ -n "${preferred}" ]] && ! port_used_by_other_state "${key}" "${preferred}" && ! port_is_listening "${preferred}"; then + printf '%s\n' "${preferred}" + return + fi + for ((port = start; port <= end; port += 1)); do + if ! port_used_by_other_state "${key}" "${port}" && ! port_is_listening "${port}"; then + printf '%s\n' "${port}" + return + fi + done + fail "端口范围 ${start}-${end} 已无可用端口。" +} + +remove_project_resources() { + local ids=() + local images=() + local volumes=() + local networks=() + [[ "${PROJECT_NAME}" =~ ^genarrative-preview-[0-9a-f]{16}$ ]] || fail "拒绝操作非白名单 compose project: ${PROJECT_NAME}" + mapfile -t ids < <(docker ps -aq --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#ids[@]}" -gt 0 ]]; then + docker rm -f "${ids[@]}" + fi + mapfile -t volumes < <(docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#volumes[@]}" -gt 0 ]]; then + docker volume rm "${volumes[@]}" + fi + mapfile -t networks < <(docker network ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#networks[@]}" -gt 0 ]]; then + docker network rm "${networks[@]}" + fi + mapfile -t images < <(docker image ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") + if [[ "${#images[@]}" -gt 0 ]]; then + docker image rm "${images[@]}" + fi +} + +compose() { + env \ + COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \ + GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \ + GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \ + GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \ + GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \ + GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \ + GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="${OTLP_GRPC_PORT}" \ + GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="${OTLP_HTTP_PORT}" \ + docker compose \ + -f "${SCRIPT_ROOT}/../deploy/container/docker-compose.loadtest.yml" \ + -f "${STATE_DIR}/docker-compose.preview.yml" \ + "$@" +} + +write_compose_override() { + local override_file="${STATE_DIR}/docker-compose.preview.yml" + cat >"${override_file}" <<'YAML' +services: + api-server: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + external-generation-worker: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + restart: on-failure + nginx: + build: + context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} + dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + spacetimedb: + mem_limit: 2g + ports: !reset [] + otelcol: + ports: !reset [] +YAML + chmod 0600 "${override_file}" +} + +wait_for_url() { + local url="$1" + local attempts="${2:-60}" + local attempt + for ((attempt = 1; attempt <= attempts; attempt += 1)); do + if curl --fail --silent --show-error --max-time 3 "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +prepare_env() { + local env_file="${STATE_DIR}/api-server.env" + local internal_secret jwt_secret + cp "${SOURCE_DIR}/deploy/container/api-server.env.example" "${env_file}" + chmod 0600 "${env_file}" + internal_secret="$(openssl rand -hex 32)" + jwt_secret="$(openssl rand -hex 32)" + node - "${env_file}" "${internal_secret}" "${jwt_secret}" <<'NODE' +const fs = require('node:fs'); +const [file, internalSecret, jwtSecret] = process.argv.slice(2); +let content = fs.readFileSync(file, 'utf8'); +content = content.replace(/^GENARRATIVE_INTERNAL_API_SECRET=.*$/mu, `GENARRATIVE_INTERNAL_API_SECRET=${internalSecret}`); +content = content.replace(/^GENARRATIVE_JWT_SECRET=.*$/mu, `GENARRATIVE_JWT_SECRET=${jwtSecret}`); +fs.writeFileSync(file, content, {encoding: 'utf8', mode: 0o600}); +NODE + unset internal_secret jwt_secret +} + +configure_spacetime_identity() { + local identity_payload spacetime_identity spacetime_token runtime_secret bootstrap_hash env_file + env_file="${STATE_DIR}/api-server.env" + identity_payload="$(curl --fail --silent --show-error --request POST \ + --header 'Accept: application/json' --header 'Content-Type: application/json' \ + "${SPACETIME_PUBLISH_URL}/v1/identity")" + spacetime_identity="$(printf '%s' "${identity_payload}" | node -e ' + let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { + const data=JSON.parse(body); process.stdout.write(data.identity || data.Identity || data.identity_hex || data.identityHex || ""); + }); + ')" + spacetime_token="$(printf '%s' "${identity_payload}" | node -e ' + let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { + const data=JSON.parse(body); process.stdout.write(data.token || data.Token || ""); + }); + ')" + [[ -n "${spacetime_identity}" && -n "${spacetime_token}" ]] || fail "SpacetimeDB identity 响应缺少 identity 或 token。" + runtime_secret="$(openssl rand -hex 32)" + bootstrap_hash="$(printf '%s' "${runtime_secret}" | sha256sum)" + bootstrap_hash="${bootstrap_hash%% *}" + { + printf '\nGENARRATIVE_SPACETIME_TOKEN=%s\n' "${spacetime_token}" + printf 'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET=%s\n' "${runtime_secret}" + } >>"${env_file}" + printf '%s\n' "${bootstrap_hash}" >"${STATE_DIR}/bootstrap-secret.sha256" + chmod 0600 "${env_file}" "${STATE_DIR}/bootstrap-secret.sha256" + spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" login --token "${spacetime_token}" >/dev/null + chmod 0600 "${STATE_DIR}/spacetime-cli.toml" + printf '[preview-deployer] SpacetimeDB identity: %s...\n' "${spacetime_identity:0:12}" + unset identity_payload spacetime_token runtime_secret bootstrap_hash +} + +deploy() { + local saved_web source_commit + [[ -d "${SOURCE_DIR}/.git" ]] || fail "缺少已经校验的源码 checkout: ${SOURCE_DIR}" + [[ -f "${SOURCE_DIR}/deploy/container/docker-compose.loadtest.yml" ]] || fail "目标提交缺少容器 compose 文件。" + [[ -f "${SOURCE_DIR}/deploy/container/api-server.env.example" ]] || fail "目标提交缺少容器 env 示例。" + source_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD^{commit})" + if [[ -n "${COMMIT_HASH}" ]]; then + local requested_commit + requested_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${COMMIT_HASH}^{commit}")" + [[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。" + fi + + saved_web="$(state_value webPort)" + WEB_PORT="${saved_web:-8400}" + SPACETIME_PORT="" + OTLP_GRPC_PORT="" + OTLP_HTTP_PORT="" + + prepare_env + write_compose_override + compose config --quiet + compose build + remove_project_resources + WEB_PORT="$(allocate_port webPort 8400 8499 "${saved_web}")" + write_state "BUILDING" "false" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" + compose up -d spacetimedb otelcol + local spacetime_container_id spacetime_ip + spacetime_container_id="$(compose ps -q spacetimedb)" + [[ -n "${spacetime_container_id}" ]] || fail "未找到 SpacetimeDB 容器。" + spacetime_ip="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${spacetime_container_id}")" + [[ -n "${spacetime_ip}" ]] || fail "未取得 SpacetimeDB 容器地址。" + SPACETIME_PUBLISH_URL="http://${spacetime_ip}:3101" + wait_for_url "${SPACETIME_PUBLISH_URL}/v1/ping" || fail "SpacetimeDB 未在超时内就绪。" + configure_spacetime_identity + + ( + cd "${SOURCE_DIR}" + # shellcheck disable=SC1091 + source scripts/jenkins-prepare-toolchain-env.sh + export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256 + GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="$(cat "${STATE_DIR}/bootstrap-secret.sha256")" + spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" \ + publish genarrative-loadtest \ + --server "${SPACETIME_PUBLISH_URL}" \ + --module-path server-rs/crates/spacetime-module \ + --yes=remote,migrate,break-clients \ + --build-options="--debug" \ + --no-config + ) + + compose up -d + wait_for_url "http://127.0.0.1:${WEB_PORT}/" || fail "Web 未在超时内就绪。" + write_state "RUNNING" "true" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" + local health_attempt + for ((health_attempt = 1; health_attempt <= 30; health_attempt += 1)); do + render_result + if node -e ' + const result = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); + if (result.healthStatus !== "HEALTHY") process.exit(1); + ' "${RESULT_FILE}"; then + return + fi + sleep 2 + done + fail "容器健康检查未全部通过。" +} + +uninstall() { + [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || fail "未找到可卸载的部署状态: ${DEPLOYMENT_ID}" + local source_commit web_port spacetime_port otlp_grpc_port otlp_http_port + source_commit="$(state_value sourceCommit)" + web_port="$(state_value webPort)" + spacetime_port="$(state_value spacetimePort)" + otlp_grpc_port="$(state_value otlpGrpcPort)" + otlp_http_port="$(state_value otlpHttpPort)" + remove_project_resources + write_state "UNINSTALLED" "false" "${source_commit}" "${web_port}" "${spacetime_port}" "${otlp_grpc_port}" "${otlp_http_port}" + render_result "UNINSTALLED" +} + +status() { + if [[ ! -f "${STATE_FILE}" ]]; then + render_result "NOT_FOUND" + return + fi + render_result +} + +validate_request +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +resolve_identity +ensure_safe_state_root +validate_existing_state + +exec 9>"${LOCK_FILE}" +flock -x 9 + +case "${ACTION}" in + DEPLOY) deploy ;; + UNINSTALL) uninstall ;; + STATUS) status ;; +esac + +SCRIPT_FAILED=0 +trap - EXIT diff --git a/scripts/preview-deployment-status.mjs b/scripts/preview-deployment-status.mjs new file mode 100644 index 000000000..19a09aa02 --- /dev/null +++ b/scripts/preview-deployment-status.mjs @@ -0,0 +1,297 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const input = parseArgs(process.argv.slice(2)); +const state = readState(input.stateFile); +const deploymentId = input.deploymentId || state?.deploymentId || ''; +const projectName = input.projectName || state?.projectName || ''; +const action = input.action || 'STATUS'; +const phaseOverride = input.phase || ''; +const containers = projectName ? inspectProjectContainers(projectName) : []; +const serviceMap = new Map( + containers.map((container) => [container.service, container]), +); +const webPort = numberOrNull(state?.webPort); +const webHost = input.webHost || '127.0.0.1'; +const webUrl = webPort ? `http://${webHost}:${webPort}` : null; + +const probes = { + web: await probe(webPort ? `http://127.0.0.1:${webPort}/` : null), + spacetime: probeSpacetimeContainer(serviceMap.get('spacetimedb')), +}; + +const requiredServices = [ + 'spacetimedb', + 'api-server', + 'external-generation-worker', + 'nginx', + 'otelcol', +]; +const services = requiredServices.map((service) => { + const container = serviceMap.get(service); + return ( + container || { + service, + containerId: null, + state: 'missing', + health: 'missing', + } + ); +}); +const containersHealthy = services.every( + (service) => + service.state === 'running' && + (service.health === 'healthy' || service.health === 'none'), +); +const probesHealthy = probes.web.ok && probes.spacetime.ok; +const active = Boolean(state?.active); + +let phase = phaseOverride || state?.phase || 'NOT_FOUND'; +let healthStatus = 'UNKNOWN'; +if (!state) { + phase = phaseOverride || 'NOT_FOUND'; + healthStatus = phase === 'FAILED' ? 'UNHEALTHY' : 'NOT_FOUND'; +} else if (phase === 'UNINSTALLED' || action === 'UNINSTALL') { + phase = 'UNINSTALLED'; + healthStatus = 'UNINSTALLED'; +} else if (phase === 'FAILED') { + healthStatus = 'UNHEALTHY'; +} else if (active && containersHealthy && probesHealthy) { + phase = 'RUNNING'; + healthStatus = 'HEALTHY'; +} else if (active || containers.length > 0) { + phase = 'UNHEALTHY'; + healthStatus = 'UNHEALTHY'; +} + +const result = { + schemaVersion: 1, + action, + deploymentId, + projectName: projectName || null, + branch: state?.sourceBranch || input.sourceBranch || null, + requestedCommit: state?.requestedCommit || null, + resolvedCommit: state?.sourceCommit || null, + status: publicStatus(phase), + health: publicHealth(healthStatus), + sourceBranch: state?.sourceBranch || input.sourceBranch || null, + sourceCommit: state?.sourceCommit || null, + phase, + healthStatus, + active: phase === 'RUNNING' || phase === 'UNHEALTHY', + webPort, + webUrl, + ports: { + web: webPort, + spacetime: null, + otlpGrpc: null, + otlpHttp: null, + }, + probes, + services, + message: input.message || defaultMessage(phase), + updatedAt: new Date().toISOString(), +}; + +writeJsonAtomic(input.output, result); +if (input.descriptionFile) { + const description = [ + deploymentId || 'unknown', + result.sourceBranch || '-', + phase, + webUrl || '-', + ].join(' | '); + writeFileSync(input.descriptionFile, `${description}\n`, 'utf8'); +} +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function parseArgs(args) { + const parsed = {}; + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (!key.startsWith('--')) { + throw new Error(`未知参数: ${key}`); + } + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw new Error(`参数缺少值: ${key}`); + } + parsed[toCamelCase(key.slice(2))] = value; + index += 1; + } + if (!parsed.output) { + throw new Error('必须提供 --output。'); + } + return parsed; +} + +function toCamelCase(value) { + return value.replace(/-([a-z])/gu, (_match, character) => + character.toUpperCase(), + ); +} + +function readState(filePath) { + if (!filePath) { + return null; + } + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function inspectProjectContainers(projectName) { + let lines = ''; + try { + lines = execFileSync( + 'docker', + [ + 'ps', + '-a', + '--filter', + `label=com.docker.compose.project=${projectName}`, + '--format', + '{{.ID}}|{{.Label "com.docker.compose.service"}}|{{.State}}', + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + } catch { + return []; + } + + return lines + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [containerId, service, state] = line.split('|'); + return { + service, + containerId, + state, + health: inspectHealth(containerId), + }; + }); +} + +function inspectHealth(containerId) { + try { + const value = execFileSync( + 'docker', + [ + 'inspect', + '--format', + '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', + containerId, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim(); + return value || 'none'; + } catch { + return 'unknown'; + } +} + +async function probe(url) { + if (!url) { + return { ok: false, status: null, elapsedMs: null }; + } + const startedAt = Date.now(); + try { + const response = await fetch(url, { signal: AbortSignal.timeout(3_000) }); + await response.body?.cancel(); + return { + ok: response.ok, + status: response.status, + elapsedMs: Date.now() - startedAt, + }; + } catch { + return { ok: false, status: null, elapsedMs: Date.now() - startedAt }; + } +} + +function probeSpacetimeContainer(container) { + if (!container?.containerId || container.state !== 'running') { + return { ok: false, status: null, elapsedMs: null }; + } + const startedAt = Date.now(); + try { + execFileSync( + 'docker', + [ + 'exec', + container.containerId, + 'spacetime', + 'server', + 'ping', + 'http://127.0.0.1:3101', + ], + { stdio: 'ignore', timeout: 3_000 }, + ); + return { ok: true, status: 200, elapsedMs: Date.now() - startedAt }; + } catch { + return { ok: false, status: null, elapsedMs: Date.now() - startedAt }; + } +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : null; +} + +function defaultMessage(phase) { + switch (phase) { + case 'RUNNING': + return '预览容器运行正常。'; + case 'UNHEALTHY': + return '预览容器已启动,但健康检查未全部通过。'; + case 'UNINSTALLED': + return '预览容器已卸载。'; + case 'NOT_FOUND': + return '未找到对应的预览部署。'; + case 'FAILED': + return '预览部署执行失败,请查看 Jenkins 构建日志。'; + default: + return `预览部署状态:${phase}`; + } +} + +function publicStatus(phase) { + switch (phase) { + case 'RUNNING': + case 'UNHEALTHY': + return 'running'; + case 'UNINSTALLED': + case 'NOT_FOUND': + return 'stopped'; + default: + return 'failed'; + } +} + +function publicHealth(healthStatus) { + switch (healthStatus) { + case 'HEALTHY': + return 'healthy'; + case 'UNHEALTHY': + return 'unhealthy'; + default: + return 'unknown'; + } +} + +function writeJsonAtomic(filePath, value) { + const absolutePath = path.resolve(filePath); + const temporaryPath = `${absolutePath}.tmp-${process.pid}`; + writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + renameSync(temporaryPath, absolutePath); +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 3c82d1717..c5cea821e 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2233,6 +2233,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -4314,6 +4320,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "preview-deployer-server" +version = "0.1.0" +dependencies = [ + "axum", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6317,10 +6342,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 1274350bf..4684a8528 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "crates/platform-speech", "crates/platform-editor-agent", "crates/pingora-gateway", + "crates/preview-deployer-server", "crates/server-manager-panel", "crates/shared-contracts", "crates/shared-kernel", diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index c0b922b10..11fdaa818 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -321,15 +321,49 @@ fn load_editor_generation_pricing_from_candidates( .or(fallback_legacy_path); if let Some(path) = selected_override_path { let override_json = fs::read_to_string(path).map_err(EditorGenerationPricingError::Io)?; - config = parse_editor_generation_pricing_json( - override_json.as_str(), - path.to_string_lossy().as_ref(), - )?; + let source = path.to_string_lossy(); + let mut override_config = + serde_json::from_str::(override_json.as_str()) + .map_err(EditorGenerationPricingError::Json)?; + backfill_legacy_sfx_pricing(&mut override_config, &config, source.as_ref())?; + override_config.validate().map_err(|error| match error { + EditorGenerationPricingError::Invalid(message) => { + EditorGenerationPricingError::Invalid(format!("{source}: {message}")) + } + other => other, + })?; + config = override_config; } config.validate()?; Ok(config) } +fn backfill_legacy_sfx_pricing( + config: &mut EditorGenerationPricingConfig, + fallback: &EditorGenerationPricingConfig, + source: &str, +) -> Result<(), EditorGenerationPricingError> { + if config + .models + .contains_key(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) + { + return Ok(()); + } + let pricing = fallback + .models + .get(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS) + .cloned() + .ok_or_else(|| { + EditorGenerationPricingError::Invalid(format!( + "{source}: 受控默认配置缺少模型 {EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS}" + )) + })?; + config + .models + .insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing); + Ok(()) +} + pub(crate) fn parse_editor_generation_pricing_json( json: &str, source: &str, @@ -739,6 +773,69 @@ mod tests { assert_eq!(loaded.sound_effect_model_mud_points(Some("audio1.0")), 17); } + #[test] + fn editor_generation_pricing_legacy_override_backfills_new_sfx_model() { + let temp_dir = unique_temp_dir("genarrative-pricing-legacy-sfx-test"); + std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); + let override_path = temp_dir.join("editor-generation-pricing.override.json"); + let mut legacy_config = default_runtime_pricing(); + legacy_config + .models + .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + legacy_config + .models + .get_mut(EDITOR_SOUND_EFFECT_MODEL_VIDU) + .expect("legacy sound effect pricing should exist") + .price = Some(17); + std::fs::write( + &override_path, + serde_json::to_string(&legacy_config).expect("legacy config should serialize"), + ) + .expect("legacy override should write"); + + let loaded = load_editor_generation_pricing_from_paths(Some(&override_path)) + .expect("legacy override should backfill the new SFX model"); + + assert_eq!( + loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)), + 5 + ); + assert_eq!( + loaded.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)), + 17 + ); + std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); + } + + #[test] + fn editor_generation_pricing_legacy_override_still_rejects_other_missing_models() { + let temp_dir = unique_temp_dir("genarrative-pricing-legacy-required-model-test"); + std::fs::create_dir_all(&temp_dir).expect("temp dir should create"); + let override_path = temp_dir.join("editor-generation-pricing.override.json"); + let mut legacy_config = default_runtime_pricing(); + legacy_config + .models + .remove(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS); + legacy_config + .models + .remove(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO); + std::fs::write( + &override_path, + serde_json::to_string(&legacy_config).expect("legacy config should serialize"), + ) + .expect("legacy override should write"); + + let error = load_editor_generation_pricing_from_paths(Some(&override_path)) + .expect_err("only the new SFX model may be backfilled"); + + assert!( + error + .to_string() + .contains(EDITOR_BACKGROUND_MUSIC_MODEL_SUNO) + ); + std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove"); + } + #[test] fn editor_image_and_spec_prices_share_model_size_rates() { assert_eq!( diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index e22f63c22..cca5e58a2 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -588,10 +588,18 @@ fn estimate_step_size(profile: &[f64], config: SnapConfig) -> Option { return None; } let threshold = maximum * config.peak_threshold_multiplier; + // The profile is a two-span central difference, so a hard block edge between + // columns `b - 1` and `b` makes `profile[b - 1]` and `profile[b]` bitwise equal: + // both straddle the same edge and sum the same per-row terms in the same order. + // A strict comparison on both sides rejects both ends of that plateau, which is + // why perfectly snapped pixel art used to yield zero peaks. Accepting equality on + // the left keeps exactly one index per plateau — the right end, which is the real + // boundary — while a strict comparison on the right still rejects the interior of + // wider constant runs. let peaks = (1..profile.len().saturating_sub(1)) .filter(|&index| { profile[index] > threshold - && profile[index] > profile[index - 1] + && profile[index] >= profile[index - 1] && profile[index] > profile[index + 1] }) .collect::>(); @@ -694,7 +702,10 @@ fn walk( let mut best_index = start; let mut best_value = -1.0f64; for (index, value) in profile.iter().enumerate().take(end).skip(start) { - if *value > best_value { + // `>=` keeps the last index of an equal run, matching the plateau end that + // `estimate_step_size` selects. Picking the first index instead would place + // every cut one pixel left of the real block boundary. + if *value >= best_value { best_value = *value; best_index = index; } @@ -878,7 +889,8 @@ fn snap_uniform_cuts( .take(end.min(profile.len().saturating_sub(1)) + 1) .skip(start) { - if *value > best_value { + // Same plateau-end convention as `walk`. + if *value >= best_value { best_value = *value; best_index = index; } diff --git a/server-rs/crates/preview-deployer-server/Cargo.toml b/server-rs/crates/preview-deployer-server/Cargo.toml new file mode 100644 index 000000000..6a9b30711 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "preview-deployer-server" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +axum = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal", "process", "fs", "io-util"] } +tower-http = { workspace = true, features = ["fs", "trace"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +http-body-util = { workspace = true } +tower = { workspace = true, features = ["util"] } diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs new file mode 100644 index 000000000..f693697bb --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -0,0 +1,240 @@ +use std::{env, fmt, path::PathBuf, time::Duration}; + +use url::Url; + +const JOB_PATH: &str = "job/shared/job/Genarrative-Preview-Deployer/"; + +#[derive(Clone)] +pub struct Config { + pub bind_address: String, + pub jenkins_root_url: Url, + pub jenkins_base_url: Url, + pub jenkins_public_base_url: Url, + pub jenkins_username: String, + pub jenkins_api_token: String, + pub git_remote_url: String, + pub git_ssh_command: Option, + pub access_token: String, + pub allowed_hosts: Vec, + pub allowed_origins: Vec, + pub preview_web_host: String, + pub secure_cookie: bool, + pub static_dir: Option, + pub state_file: PathBuf, + pub poll_interval: Duration, +} + +impl fmt::Debug for Config { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Config") + .field("bind_address", &self.bind_address) + .field("jenkins_root_url", &self.jenkins_root_url) + .field("jenkins_base_url", &self.jenkins_base_url) + .field("jenkins_public_base_url", &self.jenkins_public_base_url) + .field( + "jenkins_username_configured", + &!self.jenkins_username.is_empty(), + ) + .field( + "jenkins_api_token_configured", + &!self.jenkins_api_token.is_empty(), + ) + .field("git_remote_url", &self.git_remote_url) + .field( + "git_ssh_command_configured", + &self.git_ssh_command.is_some(), + ) + .field("access_token_configured", &!self.access_token.is_empty()) + .field("allowed_hosts", &self.allowed_hosts) + .field("allowed_origins", &self.allowed_origins) + .field("preview_web_host", &self.preview_web_host) + .field("secure_cookie", &self.secure_cookie) + .field("static_dir", &self.static_dir) + .field("state_file", &self.state_file) + .field("poll_interval", &self.poll_interval) + .finish() + } +} + +impl Config { + pub fn from_env() -> Result { + let bind_address = required("GENARRATIVE_PREVIEW_DEPLOYER_BIND")?; + let mut jenkins_root_url = Url::parse(&required( + "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL", + )?) + .map_err(|_| "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL 不是有效 URL".to_string())?; + if !matches!(jenkins_root_url.scheme(), "http" | "https") + || jenkins_root_url.host_str().is_none() + { + return Err("Jenkins base URL 只允许 http/https 绝对 URL".to_string()); + } + if !jenkins_root_url.username().is_empty() || jenkins_root_url.password().is_some() { + return Err("Jenkins base URL 不能包含用户名或密码".to_string()); + } + jenkins_root_url.set_query(None); + jenkins_root_url.set_fragment(None); + if !jenkins_root_url.path().ends_with('/') { + let path = format!("{}/", jenkins_root_url.path()); + jenkins_root_url.set_path(&path); + } + let jenkins_base_url = jenkins_root_url + .join(JOB_PATH) + .map_err(|_| "无法构造固定 Jenkins Job URL".to_string())?; + let mut jenkins_public_root_url = Url::parse(&required( + "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL", + )?) + .map_err(|_| { + "GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL 不是有效 URL".to_string() + })?; + if !matches!(jenkins_public_root_url.scheme(), "http" | "https") + || jenkins_public_root_url.host_str().is_none() + || !jenkins_public_root_url.username().is_empty() + || jenkins_public_root_url.password().is_some() + { + return Err("Jenkins public base URL 必须是无凭据的 http/https 绝对 URL".to_string()); + } + jenkins_public_root_url.set_query(None); + jenkins_public_root_url.set_fragment(None); + if !jenkins_public_root_url.path().ends_with('/') { + let path = format!("{}/", jenkins_public_root_url.path()); + jenkins_public_root_url.set_path(&path); + } + let jenkins_public_base_url = jenkins_public_root_url + .join(JOB_PATH) + .map_err(|_| "无法构造固定 Jenkins 内网 Job URL".to_string())?; + let access_token = required("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN")?; + if access_token.len() < 24 { + return Err("GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN 至少需要 24 个字符".to_string()); + } + let allowed_hosts = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS")?; + let allowed_origins = csv_required("GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS")?; + let preview_web_host = required("GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST")?; + if preview_web_host.contains(['/', ':', '@']) { + return Err( + "GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST 只能填写不带协议和端口的主机名或 IP" + .to_string(), + ); + } + for origin in &allowed_origins { + let parsed = + Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(format!( + "allowed origin 必须是无路径的 http/https 源: {origin}" + )); + } + } + + let secure_cookie = env::var("GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE") + .map(|value| value != "false") + .unwrap_or_else(|_| { + allowed_origins + .iter() + .all(|origin| origin.starts_with("https://")) + }); + let static_dir = env::var_os("GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let state_file = PathBuf::from(required("GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE")?); + validate_state_file(&state_file)?; + + Ok(Self { + bind_address, + jenkins_root_url, + jenkins_base_url, + jenkins_public_base_url, + jenkins_username: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME")?, + jenkins_api_token: required("GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN")?, + git_remote_url: validate_git_remote_url(&required( + "GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL", + )?)?, + git_ssh_command: env::var("GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND") + .ok() + .map(|value| validate_git_ssh_command(value.trim())) + .transpose()?, + access_token, + allowed_hosts, + allowed_origins, + preview_web_host, + secure_cookie, + static_dir, + state_file, + poll_interval: Duration::from_secs(2), + }) + } +} + +fn validate_git_ssh_command(value: &str) -> Result { + const TRUSTED_COMMAND: &str = "ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts"; + if value != TRUSTED_COMMAND { + return Err( + "GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND 必须使用固定只读密钥和严格主机校验参数" + .to_string(), + ); + } + Ok(value.to_string()) +} + +fn validate_git_remote_url(value: &str) -> Result { + const TRUSTED_REMOTE: &str = "ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git"; + #[cfg(test)] + if value.starts_with("test://") { + return Ok(value.to_string()); + } + if value != TRUSTED_REMOTE { + return Err(format!( + "GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL 只允许固定内网仓库 {TRUSTED_REMOTE}" + )); + } + Ok(value.to_string()) +} + +fn validate_state_file(path: &std::path::Path) -> Result<(), String> { + if !path.is_absolute() || path == std::path::Path::new("/") || path.file_name().is_none() { + return Err( + "GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE 必须是非根目录绝对文件路径".to_string(), + ); + } + let parent = path + .parent() + .ok_or_else(|| "状态文件缺少父目录".to_string())?; + let metadata = std::fs::symlink_metadata(parent) + .map_err(|_| "预览部署状态文件父目录必须已存在".to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("预览部署状态文件父目录必须是普通目录且不能是符号链接".to_string()); + } + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("预览部署状态文件必须是普通文件且不能是符号链接".to_string()); + } + } + Ok(()) +} + +fn required(name: &str) -> Result { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("缺少必需环境变量 {name}")) +} + +fn csv_required(name: &str) -> Result, String> { + let values: Vec<_> = required(name)? + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if values.is_empty() { + Err(format!("{name} 至少需要一个值")) + } else { + Ok(values) + } +} diff --git a/server-rs/crates/preview-deployer-server/src/git_refs.rs b/server-rs/crates/preview-deployer-server/src/git_refs.rs new file mode 100644 index 000000000..3c42ff719 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/git_refs.rs @@ -0,0 +1,282 @@ +use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; + +use serde::Serialize; +use tokio::{process::Command, sync::Mutex}; + +const MAX_RESULTS: usize = 20; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone)] +pub struct GitRepository { + remote_url: String, + ssh_command: Option, + cache_dir: PathBuf, + lock: Arc>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchMatch { + pub name: String, + pub commit_hash: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommitMatch { + pub commit_hash: String, + pub short_hash: String, + pub subject: String, +} + +impl GitRepository { + pub fn new(remote_url: String, ssh_command: Option, cache_dir: PathBuf) -> Self { + Self { + remote_url, + ssh_command, + cache_dir, + lock: Arc::new(Mutex::new(())), + } + } + + pub async fn search_branches(&self, query: &str) -> Result, String> { + #[cfg(test)] + if self.remote_url == "test://preview-repository" { + let candidates = [ + ( + "feature/preview-ui", + "0123456789abcdef0123456789abcdef01234567", + ), + ("feature/busy", "89abcdef0123456789abcdef0123456789abcdef"), + ]; + return Ok(candidates + .into_iter() + .filter(|(name, _)| name.contains(query)) + .map(|(name, commit_hash)| BranchMatch { + name: name.to_string(), + commit_hash: commit_hash.to_string(), + }) + .collect()); + } + let output = self + .run_remote(&["ls-remote", "--heads", &self.remote_url]) + .await?; + let query = query.to_ascii_lowercase(); + let mut matches: Vec<_> = output + .lines() + .filter_map(|line| { + let (commit_hash, reference) = line.split_once('\t')?; + let name = reference.strip_prefix("refs/heads/")?; + (name.to_ascii_lowercase().contains(&query) + && super::validate_branch(name).is_ok() + && super::validate_commit(commit_hash).is_ok()) + .then(|| BranchMatch { + name: name.to_string(), + commit_hash: commit_hash.to_ascii_lowercase(), + }) + }) + .collect(); + matches.sort_by(|left, right| { + branch_rank(&left.name, query.as_str()) + .cmp(&branch_rank(&right.name, query.as_str())) + .then_with(|| left.name.cmp(&right.name)) + }); + matches.truncate(MAX_RESULTS); + Ok(matches) + } + + pub async fn branch_exists(&self, branch: &str) -> Result { + #[cfg(test)] + if self.remote_url == "test://preview-repository" { + return Ok(matches!(branch, "feature/preview-ui" | "feature/busy")); + } + let reference = format!("refs/heads/{branch}"); + let output = self + .run_remote(&["ls-remote", "--heads", &self.remote_url, &reference]) + .await?; + Ok(output.lines().any(|line| { + line.split_once('\t') + .is_some_and(|(_, returned)| returned == reference) + })) + } + + pub async fn search_commits( + &self, + branch: &str, + query: &str, + ) -> Result, String> { + #[cfg(test)] + if self.remote_url == "test://preview-repository" { + if !self.branch_exists(branch).await? { + return Err("test branch missing".to_string()); + } + return Ok(vec![CommitMatch { + commit_hash: "0123456789abcdef0123456789abcdef01234567".to_string(), + short_hash: "0123456".to_string(), + subject: "test preview commit".to_string(), + }]); + } + let _guard = self.lock.lock().await; + self.fetch_branch(branch).await?; + let branch_ref = format!("refs/remotes/origin/{branch}"); + let output = self + .run_cached(&[ + "log", + "--format=%H%x09%h%x09%s", + "--max-count=500", + &branch_ref, + ]) + .await?; + let query = query.to_ascii_lowercase(); + let mut matches: Vec<_> = output + .lines() + .filter_map(|line| { + let mut parts = line.splitn(3, '\t'); + let hash = parts.next()?; + let short_hash = parts.next()?; + let subject = parts.next().unwrap_or_default(); + (query.is_empty() + || hash.to_ascii_lowercase().starts_with(&query) + || subject.to_ascii_lowercase().contains(&query)) + .then(|| CommitMatch { + commit_hash: hash.to_ascii_lowercase(), + short_hash: short_hash.to_ascii_lowercase(), + subject: subject.chars().take(200).collect(), + }) + }) + .take(MAX_RESULTS) + .collect(); + matches.truncate(MAX_RESULTS); + Ok(matches) + } + + pub async fn commit_belongs_to_branch( + &self, + branch: &str, + commit: &str, + ) -> Result { + #[cfg(test)] + if self.remote_url == "test://preview-repository" { + return Ok(branch == "feature/preview-ui" && commit == "abcdef1"); + } + let _guard = self.lock.lock().await; + self.fetch_branch(branch).await?; + let branch_ref = format!("refs/remotes/origin/{branch}"); + let resolved = self + .run_cached_status(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")]) + .await?; + if !resolved { + return Ok(false); + } + self.run_cached_status(&["merge-base", "--is-ancestor", commit, &branch_ref]) + .await + } + + async fn fetch_branch(&self, branch: &str) -> Result<(), String> { + self.ensure_cache().await?; + let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"); + let status = self + .command() + .arg("-C") + .arg(&self.cache_dir) + .args(["fetch", "--no-tags", "--prune", "origin", &refspec]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let status = tokio::time::timeout(COMMAND_TIMEOUT, status) + .await + .map_err(|_| "Git 分支同步超时".to_string())? + .map_err(|error| format!("无法执行 Git 分支同步: {error}"))?; + if status.success() { + Ok(()) + } else { + Err("无法从固定仓库同步目标分支".to_string()) + } + } + + async fn ensure_cache(&self) -> Result<(), String> { + if !self.cache_dir.exists() { + tokio::fs::create_dir_all(&self.cache_dir) + .await + .map_err(|error| format!("无法创建 Git 查询缓存: {error}"))?; + self.run_cached(&["init", "--bare"]).await?; + } + let metadata = tokio::fs::symlink_metadata(&self.cache_dir) + .await + .map_err(|error| format!("无法读取 Git 查询缓存: {error}"))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Git 查询缓存必须是普通目录且不能是符号链接".to_string()); + } + if !self + .run_cached_status(&["remote", "get-url", "origin"]) + .await? + { + self.run_cached(&["remote", "add", "origin", &self.remote_url]) + .await?; + } else { + let current = self.run_cached(&["remote", "get-url", "origin"]).await?; + if current.trim() != self.remote_url { + return Err("Git 查询缓存的固定远端不匹配".to_string()); + } + } + Ok(()) + } + + async fn run_remote(&self, args: &[&str]) -> Result { + self.run(self.command().args(args)).await + } + + async fn run_cached(&self, args: &[&str]) -> Result { + self.run(self.command().arg("-C").arg(&self.cache_dir).args(args)) + .await + } + + async fn run_cached_status(&self, args: &[&str]) -> Result { + let status = self + .command() + .arg("-C") + .arg(&self.cache_dir) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + Ok(tokio::time::timeout(COMMAND_TIMEOUT, status) + .await + .map_err(|_| "Git 查询超时".to_string())? + .map_err(|error| format!("无法执行 Git 查询: {error}"))? + .success()) + } + + fn command(&self) -> Command { + let mut command = Command::new("git"); + command + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_NOSYSTEM", "1"); + if let Some(value) = &self.ssh_command { + command.env("GIT_SSH_COMMAND", value); + } + command + } + + async fn run(&self, command: &mut Command) -> Result { + let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output()) + .await + .map_err(|_| "Git 查询超时".to_string())? + .map_err(|error| format!("无法执行 Git 查询: {error}"))?; + if !output.status.success() { + return Err("固定 Git 仓库查询失败".to_string()); + } + String::from_utf8(output.stdout).map_err(|_| "Git 查询返回了无效文本".to_string()) + } +} + +fn branch_rank(name: &str, query: &str) -> u8 { + let lower = name.to_ascii_lowercase(); + if lower == query { + 0 + } else if lower.starts_with(query) { + 1 + } else { + 2 + } +} diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs new file mode 100644 index 000000000..04b8deb10 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -0,0 +1,378 @@ +use std::{future::Future, time::Duration}; + +use reqwest::{Client, StatusCode, header}; +use serde::{Deserialize, de::DeserializeOwned}; +use url::Url; + +use crate::{Config, DeploymentStatus, HealthStatus}; + +#[derive(Clone)] +pub struct JenkinsClient { + http: Client, + root_url: Url, + job_url: Url, + public_job_url: Url, + username: String, + api_token: String, + poll_interval: Duration, +} + +pub enum BuildAction<'a> { + Deploy { + deployment_id: &'a str, + branch: &'a str, + commit_hash: Option<&'a str>, + }, + Uninstall { + deployment_id: &'a str, + branch: &'a str, + }, +} + +pub struct BuildReference { + queue_url: Url, +} + +pub struct BuildStarted { + pub number: u64, + pub public_url: String, +} + +impl BuildReference { + pub fn as_str(&self) -> &str { + self.queue_url.as_str() + } +} + +pub struct JenkinsOutcome { + pub success: bool, + pub cancelled: bool, + pub result: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewResult { + pub schema_version: Option, + pub action: Option, + #[serde(alias = "id")] + pub deployment_id: Option, + pub project_name: Option, + pub branch: Option, + pub resolved_commit: Option, + pub status: Option, + pub health: Option, + pub phase: Option, + pub health_status: Option, + pub web_port: Option, + pub web_url: Option, + pub message: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Crumb { + crumb_request_field: String, + crumb: String, +} + +#[derive(Deserialize)] +struct QueueItem { + cancelled: Option, + executable: Option, +} + +#[derive(Deserialize)] +struct Executable { + url: String, +} + +#[derive(Deserialize)] +struct BuildState { + building: bool, + result: Option, +} + +impl JenkinsClient { + pub fn new(config: &Config) -> Result { + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(20)) + .build() + .map_err(|error| format!("无法创建 Jenkins HTTP client: {error}"))?; + Ok(Self { + http, + root_url: config.jenkins_root_url.clone(), + job_url: config.jenkins_base_url.clone(), + public_job_url: config.jenkins_public_base_url.clone(), + username: config.jenkins_username.clone(), + api_token: config.jenkins_api_token.clone(), + poll_interval: config.poll_interval, + }) + } + + pub async fn trigger(&self, action: BuildAction<'_>) -> Result { + let mut parameters = Vec::new(); + match action { + BuildAction::Deploy { + deployment_id, + branch, + commit_hash, + } => { + parameters.push(("ACTION", "DEPLOY")); + parameters.push(("DEPLOYMENT_ID", deployment_id)); + parameters.push(("SOURCE_BRANCH", branch)); + parameters.push(("COMMIT_HASH", commit_hash.unwrap_or(""))); + } + BuildAction::Uninstall { + deployment_id, + branch, + } => { + parameters.push(("ACTION", "UNINSTALL")); + parameters.push(("DEPLOYMENT_ID", deployment_id)); + parameters.push(("SOURCE_BRANCH", branch)); + parameters.push(("COMMIT_HASH", "")); + } + } + + let trigger_url = self + .job_url + .join("buildWithParameters") + .map_err(|error| error.to_string())?; + let mut request = self + .http + .post(trigger_url) + .basic_auth(&self.username, Some(&self.api_token)) + .form(¶meters); + if let Some((field, value)) = self.crumb().await? { + let field = header::HeaderName::from_bytes(field.as_bytes()) + .map_err(|_| "Jenkins crumb header 名称无效".to_string())?; + let value = header::HeaderValue::from_str(&value) + .map_err(|_| "Jenkins crumb header 值无效".to_string())?; + request = request.header(field, value); + } + let response = request + .send() + .await + .map_err(|error| format!("Jenkins trigger 请求失败: {error}"))?; + if !response.status().is_success() { + return Err(format!("Jenkins trigger 返回 HTTP {}", response.status())); + } + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "Jenkins trigger 响应缺少 queue Location".to_string())?; + let queue_url = self.resolve_trusted_url(location)?; + Ok(BuildReference { queue_url }) + } + + pub fn restore_reference(&self, value: &str) -> Result { + Ok(BuildReference { + queue_url: self.resolve_trusted_url(value)?, + }) + } + + async fn crumb(&self) -> Result, String> { + let crumb_url = self + .root_url + .join("crumbIssuer/api/json") + .map_err(|error| error.to_string())?; + let response = self + .http + .get(crumb_url) + .basic_auth(&self.username, Some(&self.api_token)) + .send() + .await + .map_err(|error| format!("Jenkins crumb 请求失败: {error}"))?; + if matches!( + response.status(), + StatusCode::NOT_FOUND | StatusCode::FORBIDDEN + ) { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!("Jenkins crumb 返回 HTTP {}", response.status())); + } + let crumb: Crumb = response + .json() + .await + .map_err(|_| "Jenkins crumb 响应格式无效".to_string())?; + Ok(Some((crumb.crumb_request_field, crumb.crumb))) + } + + pub async fn wait_for_outcome( + &self, + reference: BuildReference, + mut on_build: F, + ) -> Result + where + F: FnMut(BuildStarted) -> Fut, + Fut: Future, + { + let build_url = loop { + let mut item_url = reference + .queue_url + .join("api/json") + .map_err(|error| error.to_string())?; + item_url + .query_pairs_mut() + .append_pair("tree", "cancelled,executable[url]"); + let item: QueueItem = self.get_json_with_retry(item_url).await?; + if item.cancelled.unwrap_or(false) { + return Ok(JenkinsOutcome { + success: false, + cancelled: true, + result: None, + }); + } + if let Some(executable) = item.executable { + let url = self.resolve_trusted_url(&executable.url)?; + break url; + } + tokio::time::sleep(self.poll_interval).await; + }; + + let build_number = parse_build_number(&build_url, &self.job_url)?; + let public_url = self.public_build_url(&build_url)?; + on_build(BuildStarted { + number: build_number, + public_url: public_url.to_string(), + }) + .await; + let successful = loop { + let mut state_url = build_url + .join("api/json") + .map_err(|error| error.to_string())?; + state_url + .query_pairs_mut() + .append_pair("tree", "building,result"); + let state: BuildState = self.get_json_with_retry(state_url).await?; + if state.building { + tokio::time::sleep(self.poll_interval).await; + continue; + } + break state.result.as_deref() == Some("SUCCESS"); + }; + if !successful { + return Ok(JenkinsOutcome { + success: false, + cancelled: false, + result: None, + }); + } + + let artifact_url = build_url + .join("artifact/preview-result.json") + .map_err(|error| error.to_string())?; + let result = self.get_json_with_retry(artifact_url).await?; + Ok(JenkinsOutcome { + success: true, + cancelled: false, + result: Some(result), + }) + } + + async fn get_json Deserialize<'de>>(&self, url: Url) -> Result { + self.ensure_same_origin(&url)?; + let response = self + .http + .get(url) + .basic_auth(&self.username, Some(&self.api_token)) + .send() + .await + .map_err(|error| format!("Jenkins 状态请求失败: {error}"))?; + if !response.status().is_success() { + return Err(format!("Jenkins 状态返回 HTTP {}", response.status())); + } + let bytes = response + .bytes() + .await + .map_err(|error| format!("Jenkins 状态响应正文读取失败: {error}"))?; + serde_json::from_slice(&bytes).map_err(|error| format!("Jenkins 状态响应格式无效: {error}")) + } + + async fn get_json_with_retry(&self, url: Url) -> Result { + const MAX_ATTEMPTS: usize = 10; + let mut last_error = None; + for attempt in 1..=MAX_ATTEMPTS { + match self.get_json(url.clone()).await { + Ok(value) => return Ok(value), + Err(error) => last_error = Some(error), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(self.poll_interval).await; + } + } + Err(last_error.unwrap_or_else(|| "Jenkins 状态请求失败".to_string())) + } + + fn resolve_trusted_url(&self, value: &str) -> Result { + let returned = Url::parse(value) + .or_else(|_| self.job_url.join(value)) + .map_err(|_| "Jenkins 返回了无效 URL".to_string())?; + if !returned.username().is_empty() + || returned.password().is_some() + || !returned.path().starts_with(self.root_url.path()) + { + return Err("Jenkins 返回了非受信源 URL".to_string()); + } + let relative_path = returned + .path() + .strip_prefix(self.root_url.path()) + .ok_or_else(|| "Jenkins 返回了非受信路径".to_string())?; + let mut trusted = self + .root_url + .join(relative_path) + .map_err(|_| "Jenkins 返回了无效 URL".to_string())?; + trusted.set_query(returned.query()); + trusted.set_fragment(None); + self.ensure_same_origin(&trusted)?; + Ok(trusted) + } + + fn ensure_same_origin(&self, url: &Url) -> Result<(), String> { + if url.username().is_empty() + && url.password().is_none() + && url.scheme() == self.root_url.scheme() + && url.host_str() == self.root_url.host_str() + && url.port_or_known_default() == self.root_url.port_or_known_default() + && url.path().starts_with(self.root_url.path()) + { + Ok(()) + } else { + Err("Jenkins 返回了非受信源 URL".to_string()) + } + } + + fn public_build_url(&self, internal: &Url) -> Result { + self.ensure_same_origin(internal)?; + let relative = internal + .path() + .strip_prefix(self.job_url.path()) + .ok_or_else(|| "Jenkins build URL 路径无效".to_string())?; + let mut public = self + .public_job_url + .join(relative) + .map_err(|_| "无法构造 Jenkins 内网构建详情地址".to_string())?; + public.set_query(None); + public.set_fragment(None); + Ok(public) + } +} + +fn parse_build_number(build_url: &Url, job_url: &Url) -> Result { + let relative = build_url + .path() + .strip_prefix(job_url.path()) + .ok_or_else(|| "Jenkins build URL 路径无效".to_string())? + .trim_end_matches('/'); + if relative.is_empty() || relative.contains('/') { + return Err("Jenkins build URL 缺少构建编号".to_string()); + } + relative + .parse::() + .ok() + .filter(|number| *number > 0) + .ok_or_else(|| "Jenkins build 编号无效".to_string()) +} diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs new file mode 100644 index 000000000..223255b1b --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -0,0 +1,1359 @@ +mod config; +mod git_refs; +mod jenkins; + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use axum::extract::DefaultBodyLimit; +use axum::{ + Json, Router, + extract::{Path, Query, Request, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::RwLock; +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; +use tracing::{error, warn}; +use url::Url; +use uuid::Uuid; + +pub use config::Config; +use git_refs::{BranchMatch, CommitMatch, GitRepository}; +use jenkins::{BuildAction, BuildReference, JenkinsClient, JenkinsOutcome}; + +const SESSION_COOKIE: &str = "genarrative_preview_session"; +const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); +const FAILED_RECORD_TTL_SECS: u64 = 7 * 24 * 60 * 60; +const STOPPED_RECORD_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +#[derive(Clone)] +pub struct AppState { + config: Arc, + jenkins: JenkinsClient, + git: GitRepository, + deployments: Arc>>, + sessions: Arc>>, +} + +impl AppState { + pub fn new(config: Config) -> Result { + let jenkins = JenkinsClient::new(&config)?; + let deployments = load_deployments(&config)?; + let git_cache_dir = config + .state_file + .parent() + .expect("validated state path has a parent") + .join(format!( + ".{}-git-ref-cache", + config + .state_file + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("preview-deployer") + )); + let git = GitRepository::new( + config.git_remote_url.clone(), + config.git_ssh_command.clone(), + git_cache_dir, + ); + Ok(Self { + config: Arc::new(config), + jenkins, + git, + deployments: Arc::new(RwLock::new(deployments)), + sessions: Arc::new(RwLock::new(HashMap::new())), + }) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum DeploymentStatus { + Queued, + Building, + Deploying, + Running, + Uninstalling, + Stopped, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum HealthStatus { + Pending, + Healthy, + Unhealthy, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Deployment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_commit: Option, + pub status: DeploymentStatus, + pub health: HealthStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jenkins_build_url: Option, + pub created_at: u64, + pub updated_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub can_uninstall: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DeploymentRecord { + public: Deployment, + #[serde(default)] + instance_id: String, + operation: Operation, + #[serde(default, skip_serializing_if = "Option::is_none")] + queue_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Operation { + Deploy, + Uninstall, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PersistedState { + schema_version: u8, + deployments: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SessionRequest { + access_token: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionResponse { + authenticated: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DeployRequest { + branch: String, + commit_hash: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BranchSearchQuery { + #[serde(default)] + q: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CommitSearchQuery { + branch: String, + #[serde(default)] + q: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BranchSearchResponse { + items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CommitSearchResponse { + items: Vec, +} + +#[derive(Debug, Serialize)] +struct DeploymentList { + deployments: Vec, +} + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: &'static str, + message: String, +} + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + code: &'static str, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_request", + message: message.into(), + } + } + + fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code: "authentication_required", + message: "请先登录预览部署控制台".to_string(), + } + } + + fn not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "deployment_not_found", + message: "预览实例不存在".to_string(), + } + } + + fn conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + code: "deployment_conflict", + message: message.into(), + } + } + + fn upstream(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + code: "jenkins_unavailable", + message: message.into(), + } + } + + fn source_ref(message: impl Into) -> Self { + Self { + status: StatusCode::UNPROCESSABLE_ENTITY, + code: "source_ref_invalid", + message: message.into(), + } + } + + fn git_unavailable() -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + code: "git_unavailable", + message: "暂时无法查询固定源码仓库".to_string(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ErrorBody { + error: self.code, + message: self.message, + }), + ) + .into_response() + } +} + +pub fn build_router(state: AppState) -> Router { + resume_monitors(&state); + let api = Router::new() + .route("/healthz", get(healthz)) + .route( + "/api/preview-deployer/session", + post(login).get(session_status).delete(logout), + ) + .route( + "/api/preview-deployer/deployments", + get(list_deployments).post(create_deployment), + ) + .route("/api/preview-deployer/refs/branches", get(search_branches)) + .route("/api/preview-deployer/refs/commits", get(search_commits)) + .route( + "/api/preview-deployer/deployments/{id}", + get(get_deployment), + ) + .route( + "/api/preview-deployer/deployments/{id}/uninstall", + post(uninstall_deployment), + ); + + let mut router = Router::new().merge(api); + if let Some(static_dir) = state.config.static_dir.clone() { + let index = static_dir.join("index.html"); + router = router + .route_service("/build", ServeFile::new(index.clone())) + .route_service("/build/", ServeFile::new(index.clone())) + .nest_service("/build/assets", ServeDir::new(static_dir.join("assets"))) + .fallback_service( + ServeDir::new(&static_dir) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(index)), + ); + } + + router + .layer(DefaultBodyLimit::max(8 * 1024)) + .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_request_boundary, + )) + .with_state(state) +} + +async fn healthz() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn enforce_request_boundary( + State(state): State, + request: Request, + next: Next, +) -> Result { + let headers = request.headers(); + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !state + .config + .allowed_hosts + .iter() + .any(|allowed| constant_time_eq(host.as_bytes(), allowed.as_bytes())) + { + return Err(ApiError { + status: StatusCode::FORBIDDEN, + code: "host_forbidden", + message: "请求 Host 不受信任".to_string(), + }); + } + + if request.uri().path().starts_with("/api/") && request.method() != axum::http::Method::GET { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !state + .config + .allowed_origins + .iter() + .any(|allowed| constant_time_eq(origin.as_bytes(), allowed.as_bytes())) + { + return Err(ApiError { + status: StatusCode::FORBIDDEN, + code: "origin_forbidden", + message: "请求 Origin 不受信任".to_string(), + }); + } + } + + Ok(next.run(request).await) +} + +async fn login( + State(state): State, + Json(payload): Json, +) -> Result { + if !constant_time_eq( + payload.access_token.as_bytes(), + state.config.access_token.as_bytes(), + ) { + return Err(ApiError::unauthorized()); + } + + let session_id = Uuid::new_v4().simple().to_string(); + let expires_at = unix_now().saturating_add(SESSION_TTL.as_secs()); + state + .sessions + .write() + .await + .insert(hash_session(&session_id), expires_at); + let secure = if state.config.secure_cookie { + "; Secure" + } else { + "" + }; + let cookie = format!( + "{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}", + SESSION_TTL.as_secs(), + secure + ); + let mut response = Json(SessionResponse { + authenticated: true, + }) + .into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).map_err(|_| ApiError::bad_request("无法创建登录会话"))?, + ); + Ok(response) +} + +async fn session_status( + State(state): State, + headers: HeaderMap, +) -> Json { + Json(SessionResponse { + authenticated: authenticated(&state, &headers).await, + }) +} + +async fn logout(State(state): State, headers: HeaderMap) -> Response { + if let Some(session_id) = session_cookie(&headers) { + state + .sessions + .write() + .await + .remove(&hash_session(&session_id)); + } + let secure = if state.config.secure_cookie { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}"); + let mut response = StatusCode::NO_CONTENT.into_response(); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +async fn list_deployments( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + prune_expired_records(&state).await; + refresh_running_health(&state).await; + let mut deployments: Vec<_> = state + .deployments + .read() + .await + .values() + .filter(|record| record.public.status != DeploymentStatus::Stopped) + .map(|record| record.public.clone()) + .collect(); + deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + Ok(Json(DeploymentList { deployments })) +} + +async fn search_branches( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + let query = validate_search_query(&query.q)?; + let branches = state.git.search_branches(&query).await.map_err(|cause| { + warn!(error = %cause, "failed to search fixed Git remote branches"); + ApiError::git_unavailable() + })?; + Ok(Json(BranchSearchResponse { items: branches })) +} + +async fn search_commits( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + let branch = validate_branch(&query.branch)?; + let query = validate_commit_search_query(&query.q)?; + if !state.git.branch_exists(&branch).await.map_err(|cause| { + warn!(error = %cause, "failed to validate branch before commit search"); + ApiError::git_unavailable() + })? { + return Err(ApiError::source_ref("分支不存在")); + } + let commits = state + .git + .search_commits(&branch, &query) + .await + .map_err(|cause| { + warn!(error = %cause, "failed to search fixed Git remote commits"); + ApiError::git_unavailable() + })?; + Ok(Json(CommitSearchResponse { items: commits })) +} + +async fn get_deployment( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, ApiError> { + require_session(&state, &headers).await?; + validate_record_id(&id)?; + refresh_running_health(&state).await; + let deployment = state + .deployments + .read() + .await + .values() + .find(|record| record.public.id.as_deref() == Some(id.as_str())) + .map(|record| record.public.clone()) + .ok_or_else(ApiError::not_found)?; + Ok(Json(deployment)) +} + +async fn refresh_running_health(state: &AppState) { + let probes: Vec<_> = state + .deployments + .read() + .await + .iter() + .filter_map(|(id, record)| { + (record.public.status == DeploymentStatus::Running) + .then(|| record.public.web_url.clone().map(|url| (id.clone(), url))) + .flatten() + }) + .collect(); + if probes.is_empty() { + return; + } + + let http = match reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(3)) + .build() + { + Ok(http) => http, + Err(_) => return, + }; + let mut tasks = tokio::task::JoinSet::new(); + for (id, url) in probes { + let http = http.clone(); + tasks.spawn(async move { + let healthy = http + .get(url) + .send() + .await + .is_ok_and(|response| response.status().is_success()); + (id, healthy) + }); + } + + let mut changed = false; + while let Some(Ok((id, healthy))) = tasks.join_next().await { + let mut deployments = state.deployments.write().await; + let Some(record) = deployments.get_mut(&id) else { + continue; + }; + let next = if healthy { + HealthStatus::Healthy + } else { + HealthStatus::Unhealthy + }; + if record.public.status == DeploymentStatus::Running && record.public.health != next { + record.public.health = next; + record.public.updated_at = unix_now(); + record.public.message = Some(if healthy { + "预览实例运行正常".to_string() + } else { + "预览实例 Web 健康检查失败".to_string() + }); + changed = true; + } + } + if changed && let Err(cause) = persist_deployments(state).await { + error!(error = %cause, "failed to persist preview health refresh"); + } +} + +async fn create_deployment( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_session(&state, &headers).await?; + prune_expired_records(&state).await; + let branch = validate_branch(&payload.branch)?; + let commit_hash = payload + .commit_hash + .as_deref() + .map(validate_commit) + .transpose()?; + if !state.git.branch_exists(&branch).await.map_err(|cause| { + warn!(error = %cause, "failed to validate branch before deployment"); + ApiError::git_unavailable() + })? { + return Err(ApiError::source_ref("分支不存在,未触发 Jenkins 构建")); + } + if let Some(commit) = commit_hash.as_deref() + && !state + .git + .commit_belongs_to_branch(&branch, commit) + .await + .map_err(|cause| { + warn!(error = %cause, "failed to validate commit before deployment"); + ApiError::git_unavailable() + })? + { + return Err(ApiError::source_ref( + "commit 不存在或不属于目标分支,未触发 Jenkins 构建", + )); + } + let id = derive_deployment_id(&branch); + let now = unix_now(); + let deployment = Deployment { + id: None, + branch: branch.clone(), + commit_hash: commit_hash.clone(), + resolved_commit: None, + status: DeploymentStatus::Queued, + health: HealthStatus::Pending, + web_port: None, + web_url: None, + jenkins_build_url: None, + created_at: now, + updated_at: now, + message: Some("等待 Jenkins 调度".to_string()), + can_uninstall: false, + }; + { + let mut deployments = state.deployments.write().await; + if deployments.get(&id).is_some_and(|record| { + matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) + }) { + return Err(ApiError::conflict("该分支已有进行中的部署操作")); + } + deployments.insert( + id.clone(), + DeploymentRecord { + public: deployment.clone(), + instance_id: id.clone(), + operation: Operation::Deploy, + queue_url: None, + }, + ); + } + persist_deployments(&state) + .await + .map_err(ApiError::upstream)?; + + let reference = match state + .jenkins + .trigger(BuildAction::Deploy { + deployment_id: &id, + branch: &branch, + commit_hash: commit_hash.as_deref(), + }) + .await + { + Ok(reference) => reference, + Err(cause) => { + mark_failed(&state, &id, "无法触发 Jenkins 构建").await; + warn!(deployment_id = %id, error = %cause, "failed to trigger preview deployment"); + return Err(ApiError::upstream("无法触发 Jenkins 构建")); + } + }; + record_queue_reference(&state, &id, &reference).await; + spawn_monitor(state, id, reference); + Ok((StatusCode::ACCEPTED, Json(deployment))) +} + +async fn uninstall_deployment( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result<(StatusCode, Json), ApiError> { + require_session(&state, &headers).await?; + validate_record_id(&id)?; + let (record_key, instance_id, branch) = { + let mut deployments = state.deployments.write().await; + let (record_key, record) = deployments + .iter_mut() + .find(|(_, record)| record.public.id.as_deref() == Some(id.as_str())) + .ok_or_else(ApiError::not_found)?; + if matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) { + return Err(ApiError::conflict("该实例当前不能卸载")); + } + if !record.public.can_uninstall { + return Err(ApiError::conflict("该实例当前没有可卸载的容器")); + } + record.public.status = DeploymentStatus::Uninstalling; + record.public.health = HealthStatus::Pending; + record.public.can_uninstall = false; + record.public.updated_at = unix_now(); + record.public.message = Some("等待 Jenkins 卸载".to_string()); + record.operation = Operation::Uninstall; + record.queue_url = None; + ( + record_key.clone(), + record.instance_id.clone(), + record.public.branch.clone(), + ) + }; + persist_deployments(&state) + .await + .map_err(ApiError::upstream)?; + + let reference = match state + .jenkins + .trigger(BuildAction::Uninstall { + deployment_id: &instance_id, + branch: &branch, + }) + .await + { + Ok(reference) => reference, + Err(cause) => { + let mut deployments = state.deployments.write().await; + if let Some(record) = deployments.get_mut(&record_key) { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = true; + record.public.updated_at = unix_now(); + record.public.message = Some("无法触发 Jenkins 卸载".to_string()); + } + drop(deployments); + if let Err(error) = persist_deployments(&state).await { + error!(deployment_id = %id, %error, "failed to persist uninstall trigger failure"); + } + warn!(deployment_id = %id, error = %cause, "failed to trigger preview uninstall"); + return Err(ApiError::upstream("无法触发 Jenkins 卸载")); + } + }; + record_queue_reference(&state, &id, &reference).await; + let deployment = state + .deployments + .read() + .await + .get(&record_key) + .expect("deployment exists") + .public + .clone(); + spawn_monitor(state, record_key, reference); + Ok((StatusCode::ACCEPTED, Json(deployment))) +} + +fn spawn_monitor(state: AppState, id: String, reference: BuildReference) { + tokio::spawn(async move { + match tokio::time::timeout( + Duration::from_secs(6 * 60 * 60), + monitor_build(&state, &id, reference), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(cause)) => { + error!(deployment_id = %id, error = %cause, "preview Jenkins monitor failed"); + mark_failed(&state, &id, "Jenkins 构建状态不可用").await; + } + Err(_) => { + error!(deployment_id = %id, "preview Jenkins monitor timed out"); + mark_failed(&state, &id, "Jenkins 构建等待超时").await; + } + } + }); +} + +fn resume_monitors(state: &AppState) { + let state = state.clone(); + tokio::spawn(async move { + prune_expired_records(&state).await; + let pending: Vec<_> = state + .deployments + .read() + .await + .iter() + .filter_map(|(id, record)| { + matches!( + record.public.status, + DeploymentStatus::Queued + | DeploymentStatus::Building + | DeploymentStatus::Deploying + | DeploymentStatus::Uninstalling + ) + .then(|| (id.clone(), record.queue_url.clone())) + }) + .collect(); + for (id, queue_url) in pending { + let Some(queue_url) = queue_url else { + mark_failed(&state, &id, "服务重启后缺少 Jenkins 队列引用").await; + continue; + }; + match state.jenkins.restore_reference(&queue_url) { + Ok(reference) => spawn_monitor(state.clone(), id, reference), + Err(cause) => { + warn!(deployment_id = %id, error = %cause, "invalid persisted Jenkins queue reference"); + mark_failed(&state, &id, "Jenkins 队列引用无效").await; + } + } + } + }); +} + +async fn record_queue_reference(state: &AppState, id: &str, reference: &BuildReference) { + if let Some(record) = state.deployments.write().await.get_mut(id) { + record.queue_url = Some(reference.as_str().to_string()); + } + if let Err(error) = persist_deployments(state).await { + error!(deployment_id = %id, %error, "failed to persist Jenkins queue reference"); + } +} + +async fn monitor_build( + state: &AppState, + id: &str, + reference: BuildReference, +) -> Result<(), String> { + let outcome = state + .jenkins + .wait_for_outcome(reference, |build| { + let state = state.clone(); + let id = id.to_string(); + async move { + let mut deployments = state.deployments.write().await; + if let Some(record) = deployments.get_mut(&id) { + record.public.id = Some(build.number.to_string()); + record.public.status = match record.operation { + Operation::Deploy => DeploymentStatus::Building, + Operation::Uninstall => DeploymentStatus::Uninstalling, + }; + record.public.jenkins_build_url = Some(build.public_url); + record.public.updated_at = unix_now(); + record.public.message = Some(match record.operation { + Operation::Deploy => "Jenkins 正在构建".to_string(), + Operation::Uninstall => "Jenkins 正在卸载".to_string(), + }); + } + drop(deployments); + if let Err(cause) = persist_deployments(&state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview build transition"); + } + } + }) + .await?; + apply_outcome(state, id, outcome).await; + Ok(()) +} + +async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { + let mut deployments = state.deployments.write().await; + let Some(record) = deployments.get_mut(id) else { + return; + }; + record.public.updated_at = unix_now(); + if outcome.cancelled { + record.public.status = DeploymentStatus::Cancelled; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = record.operation == Operation::Uninstall; + record.public.message = Some("Jenkins 构建已取消".to_string()); + } else if !outcome.success { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = record.operation == Operation::Uninstall; + record.public.message = Some("Jenkins 构建失败".to_string()); + } else if let Some(result) = outcome.result { + let expected_action = match record.operation { + Operation::Deploy => "DEPLOY", + Operation::Uninstall => "UNINSTALL", + }; + let expected_project = format!("genarrative-{id}"); + if result.schema_version != Some(1) + || result.action.as_deref() != Some(expected_action) + || result.deployment_id.as_deref() != Some(id) + || result.project_name.as_deref() != Some(expected_project.as_str()) + || result.branch.as_deref() != Some(record.public.branch.as_str()) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了不匹配的部署结果".to_string()); + } else if result + .resolved_commit + .as_deref() + .is_some_and(|value| validate_commit(value).is_err()) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了无效 commit".to_string()); + } else if result + .web_url + .as_deref() + .is_some_and(|value| !is_safe_web_url(value, &state.config.preview_web_host)) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string()); + } else if result + .web_port + .is_some_and(|value| !(8400..=8499).contains(&value)) + || result + .web_url + .as_deref() + .zip(result.web_port) + .is_some_and(|(url, port)| { + Url::parse(url).ok().and_then(|url| url.port()) != Some(port) + }) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string()); + } else if record.operation == Operation::Deploy + && (result.resolved_commit.is_none() + || result.web_port.is_none() + || result.web_url.is_none() + || result.phase.as_deref() != Some("RUNNING") + || result.health_status.as_deref() != Some("HEALTHY")) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 部署结果缺少运行或健康证明".to_string()); + } else if record.operation == Operation::Uninstall + && (result.phase.as_deref() != Some("UNINSTALLED") + || result.health_status.as_deref() != Some("UNINSTALLED")) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.can_uninstall = true; + record.public.message = Some("Jenkins 卸载结果缺少完成证明".to_string()); + } else { + if let Some(value) = result.resolved_commit { + record.public.resolved_commit = Some(value.to_ascii_lowercase()); + } + if let Some(value) = result.web_url { + record.public.web_url = Some(value); + } + if let Some(value) = result.web_port { + record.public.web_port = Some(value); + } + if let Some(value) = result.health { + record.public.health = value; + } + if let Some(value) = result.message { + record.public.message = Some(value); + } + if let Some(value) = result.status { + record.public.status = value; + } + if let Some(value) = result.phase.as_deref().and_then(map_artifact_status) { + record.public.status = value; + } + if let Some(value) = result + .health_status + .as_deref() + .and_then(map_artifact_health) + { + record.public.health = value; + } + } + } + + if outcome.success && !matches!(record.public.status, DeploymentStatus::Failed) { + match record.operation { + Operation::Deploy => { + if !matches!( + record.public.status, + DeploymentStatus::Running + | DeploymentStatus::Failed + | DeploymentStatus::Cancelled + ) { + record.public.status = DeploymentStatus::Running; + } + if record.public.health == HealthStatus::Pending { + record.public.health = HealthStatus::Unknown; + } + record.public.can_uninstall = record.public.status == DeploymentStatus::Running; + if record.public.message.is_none() { + record.public.message = Some("预览实例已发布".to_string()); + } + } + Operation::Uninstall => { + record.public.status = DeploymentStatus::Stopped; + record.public.health = HealthStatus::Unknown; + record.public.web_port = None; + record.public.web_url = None; + record.public.can_uninstall = false; + if record.public.message.is_none() { + record.public.message = Some("预览实例已卸载".to_string()); + } + } + } + } + record.queue_url = None; + drop(deployments); + if let Err(cause) = persist_deployments(state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview outcome"); + } +} + +async fn mark_failed(state: &AppState, id: &str, message: &str) { + if let Some(record) = state.deployments.write().await.get_mut(id) { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.updated_at = unix_now(); + record.public.message = Some(message.to_string()); + } + if let Err(cause) = persist_deployments(state).await { + error!(deployment_id = %id, error = %cause, "failed to persist preview failure"); + } +} + +fn load_deployments(config: &Config) -> Result, String> { + let bytes = match std::fs::read(&config.state_file) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()), + Err(error) => return Err(format!("无法读取预览部署状态文件: {error}")), + }; + let persisted: PersistedState = + serde_json::from_slice(&bytes).map_err(|_| "预览部署状态文件格式无效".to_string())?; + if persisted.schema_version != 1 { + return Err("预览部署状态文件 schemaVersion 不受支持".to_string()); + } + let mut deployments = HashMap::new(); + for mut record in persisted.deployments { + let branch = validate_branch(&record.public.branch) + .map_err(|_| "状态文件包含无效分支名".to_string())?; + if record.instance_id.is_empty() { + record.instance_id = derive_deployment_id(&branch); + } + if record.public.id.as_deref() == Some(record.instance_id.as_str()) { + record.public.id = None; + } + recover_legacy_build_details(&mut record.public, config); + validate_deployment_id(&record.instance_id) + .map_err(|_| "状态文件包含无效预览实例 ID".to_string())?; + if let Some(build_id) = record.public.id.as_deref() { + validate_record_id(build_id).map_err(|_| "状态文件包含无效构建编号".to_string())?; + } + if let Some(commit) = record.public.commit_hash.as_deref() { + validate_commit(commit).map_err(|_| "状态文件包含无效 commit".to_string())?; + } + if record + .public + .web_url + .as_deref() + .is_some_and(|value| !is_safe_web_url(value, &config.preview_web_host)) + { + return Err("状态文件包含无效 Web 地址".to_string()); + } + let url_port = record + .public + .web_url + .as_deref() + .and_then(|value| Url::parse(value).ok()) + .and_then(|url| url.port()); + if record + .public + .web_port + .is_some_and(|value| !(8400..=8499).contains(&value)) + || record + .public + .web_port + .zip(url_port) + .is_some_and(|(saved, parsed)| saved != parsed) + { + return Err("状态文件包含无效 Web 端口".to_string()); + } + if record.public.web_port.is_none() { + record.public.web_port = url_port; + } + if deployments + .insert(record.instance_id.clone(), record) + .is_some() + { + return Err("状态文件包含重复预览实例 ID".to_string()); + } + } + Ok(deployments) +} + +async fn persist_deployments(state: &AppState) -> Result<(), String> { + let mut deployments: Vec<_> = state.deployments.read().await.values().cloned().collect(); + deployments.sort_by(|left, right| left.public.created_at.cmp(&right.public.created_at)); + let bytes = serde_json::to_vec_pretty(&PersistedState { + schema_version: 1, + deployments, + }) + .map_err(|_| "无法序列化预览部署状态".to_string())?; + let parent = state + .config + .state_file + .parent() + .ok_or_else(|| "状态文件缺少父目录".to_string())?; + let temp_path = parent.join(format!( + ".preview-deployer-state-{}.tmp", + Uuid::new_v4().simple() + )); + tokio::fs::write(&temp_path, bytes) + .await + .map_err(|error| format!("无法写入预览部署临时状态: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|error| format!("无法限制预览部署状态权限: {error}"))?; + } + if let Err(error) = tokio::fs::rename(&temp_path, &state.config.state_file).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(format!("无法原子安装预览部署状态: {error}")); + } + Ok(()) +} + +async fn prune_expired_records(state: &AppState) { + let now = unix_now(); + let mut deployments = state.deployments.write().await; + let count_before = deployments.len(); + deployments.retain(|_, record| { + let age = now.saturating_sub(record.public.updated_at); + match record.public.status { + DeploymentStatus::Failed | DeploymentStatus::Cancelled + if !record.public.can_uninstall => + { + age < FAILED_RECORD_TTL_SECS + } + DeploymentStatus::Stopped => age < STOPPED_RECORD_TTL_SECS, + _ => true, + } + }); + let changed = deployments.len() != count_before; + drop(deployments); + if changed { + if let Err(cause) = persist_deployments(state).await { + error!(error = %cause, "failed to persist preview record cleanup"); + } + } +} + +async fn require_session(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + if authenticated(state, headers).await { + Ok(()) + } else { + Err(ApiError::unauthorized()) + } +} + +async fn authenticated(state: &AppState, headers: &HeaderMap) -> bool { + let Some(session_id) = session_cookie(headers) else { + return false; + }; + let key = hash_session(&session_id); + let now = unix_now(); + let mut sessions = state.sessions.write().await; + sessions.retain(|_, expiry| *expiry > now); + sessions.get(&key).is_some_and(|expiry| *expiry > now) +} + +fn session_cookie(headers: &HeaderMap) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == SESSION_COOKIE + && !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric())) + .then(|| value.to_string()) + }) +} + +fn hash_session(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +fn validate_branch(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() + || value.len() > 160 + || value.starts_with('-') + || value.starts_with('/') + || value.ends_with('/') + || value.contains("..") + || value.contains("@{") + || value.ends_with('.') + || value.ends_with(".lock") + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.')) + { + return Err(ApiError::bad_request("分支名格式无效")); + } + Ok(value.to_string()) +} + +fn validate_commit(raw: &str) -> Result { + let value = raw.trim(); + if !(7..=40).contains(&value.len()) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ApiError::bad_request( + "commit hash 必须是 7 到 40 位十六进制字符", + )); + } + Ok(value.to_ascii_lowercase()) +} + +fn validate_search_query(raw: &str) -> Result { + let value = raw.trim(); + if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) { + return Err(ApiError::bad_request("搜索关键词格式无效")); + } + Ok(value.to_ascii_lowercase()) +} + +fn validate_commit_search_query(raw: &str) -> Result { + let value = raw.trim(); + if value.len() > 80 || !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) { + return Err(ApiError::bad_request("commit 搜索关键词格式无效")); + } + Ok(value.to_ascii_lowercase()) +} + +fn derive_deployment_id(branch: &str) -> String { + let digest = format!("{:x}", Sha256::digest(branch.as_bytes())); + format!("preview-{}", &digest[..16]) +} + +fn validate_deployment_id(value: &str) -> Result<(), ApiError> { + if value.len() != 24 + || !value.starts_with("preview-") + || !value[8..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(ApiError::bad_request("部署 ID 格式无效")); + } + Ok(()) +} + +fn validate_record_id(value: &str) -> Result<(), ApiError> { + value + .parse::() + .ok() + .filter(|number| *number > 0) + .map(|_| ()) + .ok_or_else(|| ApiError::bad_request("构建编号无效")) +} + +fn recover_legacy_build_details(deployment: &mut Deployment, config: &Config) { + let Some(build_url) = deployment.jenkins_build_url.as_deref() else { + return; + }; + let Some(number) = jenkins_build_number(build_url, &config.jenkins_base_url) else { + deployment.jenkins_build_url = None; + return; + }; + if deployment.id.is_none() { + deployment.id = Some(number.to_string()); + } + deployment.jenkins_build_url = config + .jenkins_public_base_url + .join(&format!("{number}/")) + .ok() + .map(|url| url.to_string()); +} + +fn jenkins_build_number(value: &str, job_url: &Url) -> Option { + let parsed = Url::parse(value).ok()?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + parsed + .path() + .strip_prefix(job_url.path())? + .trim_end_matches('/') + .parse::() + .ok() + .filter(|number| *number > 0) +} + +fn map_artifact_status(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "QUEUED" => Some(DeploymentStatus::Queued), + "BUILDING" => Some(DeploymentStatus::Building), + "DEPLOYING" => Some(DeploymentStatus::Deploying), + "RUNNING" | "UNHEALTHY" => Some(DeploymentStatus::Running), + "UNINSTALLING" => Some(DeploymentStatus::Uninstalling), + "UNINSTALLED" | "STOPPED" | "NOT_FOUND" => Some(DeploymentStatus::Stopped), + "FAILED" => Some(DeploymentStatus::Failed), + "CANCELLED" => Some(DeploymentStatus::Cancelled), + _ => None, + } +} + +fn map_artifact_health(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "PENDING" => Some(HealthStatus::Pending), + "HEALTHY" => Some(HealthStatus::Healthy), + "UNHEALTHY" => Some(HealthStatus::Unhealthy), + "UNKNOWN" | "UNINSTALLED" | "NOT_FOUND" => Some(HealthStatus::Unknown), + _ => None, + } +} + +fn is_safe_web_url(value: &str, expected_host: &str) -> bool { + url::Url::parse(value).is_ok_and(|url| { + url.scheme() == "http" + && url.host_str() == Some(expected_host) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url.port().is_some_and(|port| (8400..=8499).contains(&port)) + }) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + let length = left.len().max(right.len()); + for index in 0..length { + difference |= usize::from(*left.get(index).unwrap_or(&0) ^ *right.get(index).unwrap_or(&0)); + } + difference == 0 +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/preview-deployer-server/src/main.rs b/server-rs/crates/preview-deployer-server/src/main.rs new file mode 100644 index 000000000..394735789 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/main.rs @@ -0,0 +1,42 @@ +use preview_deployer_server::{AppState, Config, build_router}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,tower_http=info")), + ) + .init(); + + let config = Config::from_env().unwrap_or_else(|error| { + eprintln!("预览部署服务配置无效: {error}"); + std::process::exit(2); + }); + let listener = tokio::net::TcpListener::bind(&config.bind_address) + .await + .unwrap_or_else(|error| { + eprintln!("预览部署服务监听失败: {error}"); + std::process::exit(2); + }); + let address = listener + .local_addr() + .expect("bound listener has local address"); + let state = AppState::new(config).unwrap_or_else(|error| { + eprintln!("预览部署服务初始化失败: {error}"); + std::process::exit(2); + }); + info!(%address, "preview deployer server started"); + + axum::serve(listener, build_router(state)) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .unwrap_or_else(|error| { + eprintln!("预览部署服务退出: {error}"); + std::process::exit(1); + }); +} diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs new file mode 100644 index 000000000..98b2b5511 --- /dev/null +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -0,0 +1,823 @@ +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, + routing::{get, post}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tower::ServiceExt; +use url::Url; + +use super::{AppState, Config, build_router}; + +const HOST: &str = "preview.internal:8080"; +const ORIGIN: &str = "http://preview.internal:8080"; + +#[derive(Clone, Default)] +struct MockJenkinsState { + requests: Arc>>, + invalid_build_responses: Arc, +} + +#[derive(Debug)] +struct RecordedRequest { + path: String, + body: String, + crumb: Option, +} + +async fn start_mock_jenkins() -> (Url, MockJenkinsState) { + let state = MockJenkinsState::default(); + let app = Router::new() + .route("/jenkins/crumbIssuer/api/json", get(|| async { + Json(json!({"crumbRequestField": "Jenkins-Crumb", "crumb": "safe-crumb"})) + })) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters", post(mock_trigger)) + .route("/jenkins/queue/item/{id}/api/json", get(mock_queue)) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/api/json", get(mock_build)) + .route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/artifact/preview-result.json", get(mock_artifact)) + .with_state(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock Jenkins binds"); + let address = listener.local_addr().expect("mock Jenkins address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock Jenkins serves"); + }); + ( + Url::parse(&format!("http://{address}/jenkins/")).unwrap(), + state, + ) +} + +async fn mock_trigger( + State(state): State, + headers: HeaderMap, + request: Request, +) -> impl IntoResponse { + let body = request + .into_body() + .collect() + .await + .expect("form body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 form"); + let mut requests = state.requests.lock().expect("request lock"); + let build_number = requests.len() + 1; + requests.push(RecordedRequest { + path: "/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters" + .to_string(), + body, + crumb: headers + .get("jenkins-crumb") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned), + }); + let mut response = StatusCode::CREATED.into_response(); + response.headers_mut().insert( + header::LOCATION, + format!("/jenkins/queue/item/{build_number}/") + .parse() + .unwrap(), + ); + response +} + +async fn mock_queue(axum::extract::Path(id): axum::extract::Path) -> Json { + Json( + json!({"cancelled": false, "executable": {"url": format!("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}), + ) +} + +async fn mock_build( + State(state): State, + uri: axum::http::Uri, +) -> impl IntoResponse { + if uri.query() != Some("tree=building%2Cresult") { + return (StatusCode::BAD_REQUEST, "missing bounded tree query").into_response(); + } + if state + .invalid_build_responses + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return (StatusCode::OK, "Jenkins is finalizing the build").into_response(); + } + Json(json!({"building": false, "result": "SUCCESS"})).into_response() +} + +async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) -> Json { + if build == 1 { + Json(json!({ + "schemaVersion": 1, + "action": "DEPLOY", + "deploymentId": "preview-63d38d3da6bc9b06", + "projectName": "genarrative-preview-63d38d3da6bc9b06", + "branch": "feature/preview-ui", + "resolvedCommit": "0123456789abcdef0123456789abcdef01234567", + "phase": "RUNNING", + "healthStatus": "HEALTHY", + "webPort": 8400, + "webUrl": "http://192.168.35.82:8400", + "message": "预览实例已发布" + })) + } else { + Json(json!({ + "schemaVersion": 1, + "action": "UNINSTALL", + "deploymentId": "preview-63d38d3da6bc9b06", + "projectName": "genarrative-preview-63d38d3da6bc9b06", + "branch": "feature/preview-ui", + "phase": "UNINSTALLED", + "healthStatus": "UNINSTALLED", + "message": "预览实例已卸载" + })) + } +} + +fn test_config(jenkins_base_url: Url) -> Config { + let job_url = jenkins_base_url + .join("job/shared/job/Genarrative-Preview-Deployer/") + .unwrap(); + Config { + bind_address: "127.0.0.1:0".to_string(), + jenkins_root_url: jenkins_base_url.clone(), + jenkins_base_url: job_url, + jenkins_public_base_url: Url::parse( + "http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/", + ) + .unwrap(), + jenkins_username: "preview-service".to_string(), + jenkins_api_token: "server-only-jenkins-token".to_string(), + git_remote_url: "test://preview-repository".to_string(), + git_ssh_command: None, + access_token: "correct horse battery staple".to_string(), + allowed_hosts: vec![HOST.to_string()], + allowed_origins: vec![ORIGIN.to_string()], + preview_web_host: "192.168.35.82".to_string(), + secure_cookie: false, + static_dir: None, + state_file: std::env::temp_dir().join(format!( + "preview-deployer-test-{}.json", + uuid::Uuid::new_v4().simple() + )), + poll_interval: Duration::from_millis(5), + } +} + +fn api_request(method: &str, uri: &str, body: Body) -> axum::http::Request { + axum::http::Request::builder() + .method(method) + .uri(uri) + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::CONTENT_TYPE, "application/json") + .body(body) + .unwrap() +} + +async fn login_cookie(app: &Router) -> String { + let response = app + .clone() + .oneshot(api_request( + "POST", + "/api/preview-deployer/session", + Body::from(r#"{"accessToken":"correct horse battery staple"}"#), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Strict")); + cookie.split(';').next().unwrap().to_string() +} + +#[tokio::test] +async fn session_and_request_boundaries_reject_anonymous_or_cross_origin_requests() { + let (jenkins_url, _) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + + let anonymous = app + .clone() + .oneshot(api_request( + "GET", + "/api/preview-deployer/deployments", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + + let bad_login = app + .clone() + .oneshot(api_request( + "POST", + "/api/preview-deployer/session", + Body::from(r#"{"accessToken":"wrong"}"#), + )) + .await + .unwrap(); + assert_eq!(bad_login.status(), StatusCode::UNAUTHORIZED); + + let cross_origin = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/session") + .header(header::HOST, HOST) + .header(header::ORIGIN, "http://evil.invalid") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"accessToken":"correct horse battery staple"}"#, + )) + .unwrap(); + assert_eq!( + app.clone().oneshot(cross_origin).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let bad_host = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/session") + .header(header::HOST, "evil.invalid") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.oneshot(bad_host).await.unwrap().status(), + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn healthz_is_available_without_a_control_session() { + let (jenkins_url, _) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + let response = app + .oneshot( + axum::http::Request::builder() + .method("GET") + .uri("/healthz") + .header(header::HOST, HOST) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[test] +fn config_debug_never_exposes_access_or_jenkins_tokens() { + let root = Url::parse("http://127.0.0.1:8080/jenkins/").unwrap(); + let config = test_config(root); + let debug = format!("{config:?}"); + assert!(!debug.contains("server-only-jenkins-token")); + assert!(!debug.contains("correct horse battery staple")); + assert!(debug.contains("jenkins_api_token_configured: true")); + assert!(debug.contains("access_token_configured: true")); +} + +#[tokio::test] +async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { + let (jenkins_url, mock) = start_mock_jenkins().await; + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + + let invalid = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"branch":"--upload-pack=evil"}"#)) + .unwrap(); + assert_eq!( + app.clone().oneshot(invalid).await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + assert!(mock.requests.lock().unwrap().is_empty()); + + let id = super::derive_deployment_id("feature/preview-ui"); + assert_eq!(id, "preview-63d38d3da6bc9b06"); + let deploy_request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"branch":"feature/preview-ui","commitHash":"abcdef1"}"#, + )) + .unwrap(); + assert_eq!( + app.clone().oneshot(deploy_request).await.unwrap().status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Running) + .await + .expect("deployment reaches running"); + + let requests = mock.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters" + ); + assert_eq!(requests[0].crumb.as_deref(), Some("safe-crumb")); + assert!(requests[0].body.contains("ACTION=DEPLOY")); + assert!( + requests[0] + .body + .contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06") + ); + assert!( + requests[0] + .body + .contains("SOURCE_BRANCH=feature%2Fpreview-ui") + ); + assert!(requests[0].body.contains("COMMIT_HASH=abcdef1")); + assert!(!requests[0].body.contains("JOB")); + drop(requests); + + let deployment = state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .clone(); + assert_eq!(deployment.status, super::DeploymentStatus::Running); + assert_eq!(deployment.id.as_deref(), Some("1")); + assert_eq!( + deployment.jenkins_build_url.as_deref(), + Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/1/") + ); + assert_eq!(deployment.health, super::HealthStatus::Healthy); + assert_eq!(deployment.web_port, Some(8400)); + assert_eq!( + deployment.web_url.as_deref(), + Some("http://192.168.35.82:8400") + ); + assert_eq!( + deployment.resolved_commit.as_deref(), + Some("0123456789abcdef0123456789abcdef01234567") + ); + assert!(deployment.can_uninstall); + + let uninstall_request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments/1/uninstall") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(); + assert_eq!( + app.clone() + .oneshot(uninstall_request) + .await + .unwrap() + .status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Stopped) + .await + .expect("deployment reaches stopped"); + let requests = mock.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests[1].body.contains("ACTION=UNINSTALL")); + assert!( + requests[1] + .body + .contains("SOURCE_BRANCH=feature%2Fpreview-ui") + ); + assert!( + requests[1] + .body + .contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06") + ); + assert!(!requests[1].body.contains("CONTAINER")); + drop(requests); + + let deployment = state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .clone(); + assert_eq!(deployment.status, super::DeploymentStatus::Stopped); + assert_eq!(deployment.health, super::HealthStatus::Unknown); + assert_eq!(deployment.web_port, None); + assert_eq!(deployment.web_url, None); + assert!(!deployment.can_uninstall); + + let list_request = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let list_response = app.clone().oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let list: Value = serde_json::from_slice(&list_body).unwrap(); + assert_eq!(list["deployments"], json!([])); + let state_file = state.config.state_file.clone(); + let mut recovered_config = test_config(state.config.jenkins_root_url.clone()); + recovered_config.state_file = state_file.clone(); + let recovered = AppState::new(recovered_config); + assert!(recovered.is_ok()); + assert_eq!( + recovered + .unwrap() + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .status, + super::DeploymentStatus::Stopped + ); + std::fs::remove_file(state_file).unwrap(); +} + +#[tokio::test] +async fn ref_search_requires_session_and_returns_server_side_matches() { + let (jenkins_url, _) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + let anonymous = app + .clone() + .oneshot(api_request( + "GET", + "/api/preview-deployer/refs/branches?q=preview", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + let cookie = login_cookie(&app).await; + let request = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/refs/branches?q=preview") + .header(header::HOST, HOST) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["items"][0]["name"], "feature/preview-ui"); + + let request = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/refs/commits?branch=feature%2Fpreview-ui&q=0123456") + .header(header::HOST, HOST) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + value["items"][0]["commitHash"], + "0123456789abcdef0123456789abcdef01234567" + ); + assert_eq!(value["items"][0]["shortHash"], "0123456"); +} + +#[tokio::test] +async fn deployment_rejects_missing_or_unrelated_refs_before_jenkins() { + let (jenkins_url, mock) = start_mock_jenkins().await; + let app = build_router(AppState::new(test_config(jenkins_url)).unwrap()); + let cookie = login_cookie(&app).await; + for payload in [ + r#"{"branch":"feature/missing"}"#, + r#"{"branch":"feature/preview-ui","commitHash":"deadbee"}"#, + ] { + let request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(payload)) + .unwrap(); + assert_eq!( + app.clone().oneshot(request).await.unwrap().status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + } + assert!(mock.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn transient_invalid_build_status_is_retried_before_reading_artifact() { + let (jenkins_url, mock) = start_mock_jenkins().await; + mock.invalid_build_responses.store(1, Ordering::SeqCst); + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + let id = super::derive_deployment_id("feature/preview-ui"); + let deploy_request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"branch":"feature/preview-ui"}"#)) + .unwrap(); + assert_eq!( + app.oneshot(deploy_request).await.unwrap().status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Running) + .await + .expect("transient invalid Jenkins response is retried"); + assert_eq!( + state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .health, + super::HealthStatus::Healthy + ); + std::fs::remove_file(&state.config.state_file).unwrap(); +} + +#[tokio::test] +async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { + let (jenkins_url, mock) = start_mock_jenkins().await; + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + let id = super::derive_deployment_id("feature/busy"); + let now = super::unix_now(); + state.deployments.write().await.insert( + id.clone(), + super::DeploymentRecord { + public: super::Deployment { + id: None, + branch: "feature/busy".to_string(), + commit_hash: None, + resolved_commit: None, + status: super::DeploymentStatus::Building, + health: super::HealthStatus::Pending, + web_port: None, + web_url: None, + jenkins_build_url: None, + created_at: now, + updated_at: now, + message: None, + can_uninstall: false, + }, + instance_id: id, + operation: super::Operation::Deploy, + queue_url: None, + }, + ); + let request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"branch":"feature/busy"}"#)) + .unwrap(); + assert_eq!( + app.oneshot(request).await.unwrap().status(), + StatusCode::CONFLICT + ); + assert!(mock.requests.lock().unwrap().is_empty()); +} + +async fn wait_for_status( + state: &AppState, + id: &str, + expected: super::DeploymentStatus, +) -> Result<(), ()> { + for _ in 0..100 { + if state + .deployments + .read() + .await + .get(id) + .is_some_and(|record| record.public.status == expected) + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + Err(()) +} + +#[test] +fn branch_commit_and_web_url_validation_are_strict() { + assert!(super::validate_branch("feature/preview-ui").is_ok()); + assert!(super::validate_branch("../master").is_err()); + assert!(super::validate_branch("main;curl evil").is_err()); + assert!(super::validate_commit("abcdef1").is_ok()); + assert!(super::validate_commit("HEAD~1").is_err()); + assert!(super::validate_deployment_id("preview-63d38d3da6bc9b06").is_ok()); + assert!(super::validate_deployment_id("preview-63D38D3DA6BC9B06").is_err()); + assert!(super::is_safe_web_url( + "http://192.168.35.82:8400", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "javascript:alert(1)", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://user:pass@preview.internal", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://192.168.35.82:8500", + "192.168.35.82" + )); + assert!(!super::is_safe_web_url( + "http://station.genarrative.world:8400", + "192.168.35.82" + )); +} + +#[test] +fn legacy_running_state_recovers_web_port_from_validated_url() { + let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap()); + let id = super::derive_deployment_id("feature/legacy-running"); + std::fs::write( + &config.state_file, + serde_json::to_vec(&json!({ + "schemaVersion": 1, + "deployments": [{ + "public": { + "id": id, + "branch": "feature/legacy-running", + "status": "running", + "health": "healthy", + "webUrl": "http://192.168.35.82:8407", + "jenkinsBuildUrl": "http://127.0.0.1:18080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/", + "createdAt": 1, + "updatedAt": 2, + "canUninstall": true + }, + "operation": "deploy" + }] + })) + .unwrap(), + ) + .unwrap(); + + let state = AppState::new(config).unwrap(); + assert_eq!( + state + .deployments + .blocking_read() + .get(&id) + .unwrap() + .public + .web_port, + Some(8407) + ); + let deployment = state + .deployments + .blocking_read() + .get(&id) + .unwrap() + .public + .clone(); + assert_eq!(deployment.id.as_deref(), Some("42")); + assert_eq!( + deployment.jenkins_build_url.as_deref(), + Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/") + ); + std::fs::remove_file(&state.config.state_file).unwrap(); +} + +#[tokio::test] +async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_retained() { + let (jenkins_url, _) = start_mock_jenkins().await; + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + let old = super::unix_now().saturating_sub(super::FAILED_RECORD_TTL_SECS + 1); + let failed_id = super::derive_deployment_id("feature/old-failure"); + let uninstallable_id = super::derive_deployment_id("feature/failed-uninstall"); + state.deployments.write().await.extend([ + ( + failed_id.clone(), + super::DeploymentRecord { + public: super::Deployment { + id: Some("91".to_string()), + branch: "feature/old-failure".to_string(), + commit_hash: None, + resolved_commit: None, + status: super::DeploymentStatus::Failed, + health: super::HealthStatus::Unknown, + web_port: None, + web_url: None, + jenkins_build_url: None, + created_at: old, + updated_at: old, + message: None, + can_uninstall: false, + }, + instance_id: failed_id.clone(), + operation: super::Operation::Deploy, + queue_url: None, + }, + ), + ( + uninstallable_id.clone(), + super::DeploymentRecord { + public: super::Deployment { + id: Some("92".to_string()), + branch: "feature/failed-uninstall".to_string(), + commit_hash: None, + resolved_commit: None, + status: super::DeploymentStatus::Failed, + health: super::HealthStatus::Unknown, + web_port: Some(8401), + web_url: Some("http://192.168.35.82:8401".to_string()), + jenkins_build_url: None, + created_at: old, + updated_at: old, + message: None, + can_uninstall: true, + }, + instance_id: uninstallable_id.clone(), + operation: super::Operation::Uninstall, + queue_url: None, + }, + ), + ]); + + let response = app + .oneshot( + axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let listed: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(listed["deployments"].as_array().unwrap().len(), 1); + assert_eq!(listed["deployments"][0]["id"], "92"); + assert!(!state.deployments.read().await.contains_key(&failed_id)); + assert!( + state + .deployments + .read() + .await + .contains_key(&uninstallable_id) + ); + std::fs::remove_file(&state.config.state_file).unwrap(); +} diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 2038848fc..405196d88 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -780,6 +780,8 @@ pub struct GameCreationAppManifest { pub command_runs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub versions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub godot_project_root: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -912,6 +914,7 @@ pub fn new_game_creation_app_manifest( preview: None, command_runs: Vec::new(), versions: Vec::new(), + godot_project_root: None, } } @@ -1507,6 +1510,7 @@ mod tests { #[test] fn seed_manifest_includes_professional_group_tasks() { let manifest = new_game_creation_app_manifest("project-1", "像素动作原型"); + assert_eq!(manifest.godot_project_root, None); let ids = manifest .tasks .iter() @@ -1742,6 +1746,7 @@ mod tests { #[test] fn manifest_uses_camel_case_and_canvas_source() { let mut manifest = new_game_creation_app_manifest("project-1", "像素动作原型"); + manifest.godot_project_root = Some("game-source".to_string()); manifest.preview = Some(GameCreationAppPreviewState { status: GameCreationAppPreviewStatus::Running, url: Some("http://127.0.0.1:3001/".to_string()), @@ -1808,6 +1813,7 @@ mod tests { json!("player") ); assert_eq!(payload["versions"][0]["createdReason"], json!("initial")); + assert_eq!(payload["godotProjectRoot"], json!("game-source")); } #[test] diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index cd4e225ab..634ca8275 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -371,6 +371,11 @@ describe('ImageCanvasContextMenusView', () => { mediaType: 'audio' as const, assetKind: 'background-music' as const, }, + { + title: '视频', + mediaType: 'video' as const, + assetKind: 'video' as const, + }, ])('hides quick edit from both menus for $title', (overrides) => { const layer = createLayer(overrides); renderContextMenus({ diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 313160036..e0a5406ae 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -1795,7 +1795,6 @@ export function ImageCanvasGenerationComposerView({ {quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? ( { isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'video', + assetKind: null, + }), + ).toBe(false); + expect( + isQuickEditSupportedLayer({ + ...buildSourceLayer(), + mediaType: 'image', assetKind: 'video', }), - ).toBe(true); + ).toBe(false); }); it('adds reference image semantics and snapshots for spec generation references', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 409572301..6e7fee548 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -712,7 +712,6 @@ export function buildQuickEditModelOptions(currentModel: string) { const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set([ undefined, 'image', - 'video', ]); const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set([ @@ -725,7 +724,6 @@ const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set([ 'publication-material', 'ui-design', 'scene', - 'video', ]); export function isQuickEditSupportedLayer(layer: CanvasLayer) { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index f77074663..7a7ec1dd8 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -424,6 +424,30 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('rejects persisted video quick-edit dialogs during submission planning', () => { + const sourceLayer = createLayer({ + title: '历史视频', + src: 'https://assets.example.test/history.mp4', + mediaType: 'video', + assetKind: 'video', + originalWidth: 854, + originalHeight: 480, + }); + + expect(() => + buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前画面改成雨天', + status: 'idle', + sourceLayerId: sourceLayer.id, + }, + layers: [sourceLayer], + nextGeneratedIndex: 4, + }), + ).toThrow('当前素材类型不支持快速编辑'); + }); + it('does not send image style from an incomplete quick-edit snapshot', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 14edd6af1..37e7c71bb 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -54,6 +54,7 @@ import { IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, + isQuickEditSupportedLayer, normalizeEditorImageModel, resolveCharacterAnimationSourceImageSrc, resolveEditorImageGenerationPixelSize, @@ -577,6 +578,9 @@ export function buildImageGenerationSubmissionPlan({ if (!sourceLayer) { throw new Error('未找到要改造的原图'); } + if (!isQuickEditSupportedLayer(sourceLayer)) { + throw new Error('当前素材类型不支持快速编辑'); + } const basePrompt = dialog.prompt.trim() || '快速编辑图片'; const normalizedQuickEditPrompt = basePrompt; const requestedImageModel = normalizeEditorImageModel( diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index a7d9e6b28..60b2234b6 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -6,30 +6,12 @@ import { describe, expect, it, vi } from 'vitest'; import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils'; import type { - CanvasLayer, CharacterReferenceImage, QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; -function createLayer(): CanvasLayer { - return { - id: 'layer-a', - resourceId: 'resource-a', - title: '源图', - src: 'data:image/png;base64,c291cmNl', - x: 0, - y: 0, - width: 320, - height: 180, - originalWidth: 320, - originalHeight: 180, - zIndex: 1, - sourceType: 'uploaded', - }; -} - function QuickEditPanelHarness({ initialPanel, onSubmit = vi.fn(), @@ -50,7 +32,6 @@ function QuickEditPanelHarness({
; isReferenceMenuOpen?: boolean; @@ -92,7 +90,6 @@ function syncPanelFromDialogUpdate( export function ImageCanvasQuickEditPanelView({ panel, - sourceLayer, style, referenceButtonRef, isReferenceMenuOpen = false, @@ -106,7 +103,6 @@ export function ImageCanvasQuickEditPanelView({ hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasQuickEditPanelViewProps) { - const isImageQuickEdit = sourceLayer.mediaType !== 'video'; const referenceCount = panel.quickEditReferences?.length ?? 0; const canAddReferences = referenceCount < @@ -148,7 +144,7 @@ export function ImageCanvasQuickEditPanelView({ hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmit} dialogLabel="快速编辑图片" - includeDimensions={isImageQuickEdit} + includeDimensions={true} includeModel={true} includeReferences={false} promptLabel="快速编辑提示词" diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 2bf656347..2f2146ed2 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -444,7 +444,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { within(videoToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['快速编辑', '改造', '下载按钮']); + ).toEqual(['改造', '下载按钮']); cleanup(); renderSelectedToolbar({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 7c6531de2..a16367761 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -337,14 +337,12 @@ function SubmissionWorkflowHarness({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState: initialQuickEditSelectionState, - canvasGenerationDialogs: dialogs.canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog: activeCanvasDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog: dialogs.setGenerateDialog, - openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById, @@ -1496,47 +1494,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); - it('submits video quick edits through the video API with the source video', async () => { - const applyProjectSnapshot = vi.fn(); - const upsertGeneratedAsset = vi.fn(); - generateEditorVideoMock.mockResolvedValueOnce( - createVideoGenerated({ - prompt: '把当前图改成雨天', - project: { - projectId: 'editor-project-1', - title: '项目', - coverImageSrc: null, - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - createdAt: '2026-06-27T00:00:00.000Z', - updatedAt: '2026-06-27T00:00:00.000Z', - }, - asset: { - assetId: 'asset-video-quick-edit', - folderId: 'user-1:asset-folder:project', - label: '源视频 快速编辑', - imageSrc: 'https://assets.example.test/generated-video.mp4', - thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', - width: 854, - height: 480, - sourceType: 'generated', - assetKind: 'video', - }, - }), - ); + it('rejects direct video quick-edit submission without calling generation APIs', () => { render( { size: '854x480', model: 'gpt-image-2', status: 'idle', - quickEditReferences: [ - { - id: 'ref-style', - label: '风格参考', - src: 'https://assets.example.test/style.png', - }, - ], }} />, ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); - await waitFor(() => { - expect(generateEditorVideoMock).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '把当前图改成雨天', - model: 'seedance2.0-fast', - aspectRatio: '16:9', - durationSeconds: 4, - resolution: '480p', - mode: 'std', - sound: 'off', - referenceVideoSrcs: [ - 'generated-character-drafts/editor/generation-references/reference.png', - ], - generationInputs: expect.objectContaining({ - version: 2, - action: 'video.generate', - references: [ - { - id: 'videoReference', - title: '参考视频 1', - label: '源视频', - refType: 'project-resource', - refId: 'resource-source', - }, - ], - }), - assetFolderId: 'project', - assetLabel: '源视频 快速编辑', - }), - ); - }); - expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'referenceImageSrcs', - ); + expect(editEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); + expect(generateEditorVideoMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('quick-edit').textContent).toContain( + 'failed:把当前图改成雨天:当前素材类型不支持快速编辑', + ); + }); + + it('rejects a hydrated video quick-edit dialog before calling generation APIs', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + await waitFor(() => { - expect(applyProjectSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ projectId: 'editor-project-1' }), - ); - expect(upsertGeneratedAsset).toHaveBeenCalledWith( - expect.objectContaining({ - assetId: 'asset-video-quick-edit', - thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', - }), + expect(screen.getByTestId('tracked-dialog').textContent).toContain( + 'quick-edit:把当前画面改成雨天:failed:open:当前素材类型不支持快速编辑', ); }); + expect(editEditorImageMock).not.toHaveBeenCalled(); + expect(generateEditorImageMock).not.toHaveBeenCalled(); + expect(generateEditorVideoMock).not.toHaveBeenCalled(); }); it('uploads inline video references and keeps image objectKey and asset URLs', async () => { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index d97836218..dd34d9b14 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -50,7 +50,6 @@ import type { QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; -import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; import { applyImageEditResultToSourceLayer, createAudioResultLayer, @@ -63,12 +62,9 @@ import { buildCharacterAnimationGenerationInputs, buildQuickEditGenerationInputs, buildUiDesignAssetExtractionGenerationInputs, - buildVideoGenerationInputs, - createCanvasLayerReference, DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_IMAGE_MODEL, - DEFAULT_VIDEO_MODEL, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isCanvasGenerationDialog, @@ -77,7 +73,6 @@ import { resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; -import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, @@ -338,7 +333,6 @@ type GenerationSubmissionWorkflowOptions = { quickEditPanel: QuickEditPanelState | null; quickEditSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; - canvasGenerationDialogs: CanvasGenerationDialogState[]; setQuickEditPanel: Dispatch>; characterAnimationPanel: CharacterAnimationPanelState | null; characterAnimationDialog: CanvasGenerationDialogState | null; @@ -347,9 +341,6 @@ type GenerationSubmissionWorkflowOptions = { SetStateAction >; setGenerateDialog: Dispatch>; - openCanvasGenerationDialog: ( - dialog: Omit, - ) => string; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, @@ -650,10 +641,6 @@ function buildRightSideCanvasCompletionPlaceholder( }; } -function isVideoQuickEditSource(layer: CanvasLayer) { - return layer.mediaType === 'video' || layer.assetKind === 'video'; -} - export function useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize, @@ -662,14 +649,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog, - openCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById, @@ -1451,63 +1436,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const normalizedQuickEditModel = normalizeEditorImageModel( quickEditPanel.model, ); - const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer) - ? { - width: quickEditSourceLayer.originalWidth, - height: quickEditSourceLayer.originalHeight, - } - : resolveEditorImageGenerationPixelSize({ - model: normalizedQuickEditModel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - }); + const quickEditOutputSize = resolveEditorImageGenerationPixelSize({ + model: normalizedQuickEditModel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + }); const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`; const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; - let quickEditDialogId: string | undefined; - const shouldUseCanvasPlaceholder = - isVideoQuickEditSource(quickEditSourceLayer); - if (shouldUseCanvasPlaceholder) { - const quickEditDraft = createQuickEditGenerationDialogDraft({ - sourceLayer: quickEditSourceLayer, - prompt: normalizedPrompt, - status: 'generating', - references: quickEditReferences, - model: quickEditPanel.model, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - frame: { - width: quickEditOutputSize.width, - height: quickEditOutputSize.height, - }, - assetLabel: quickEditResultTitle, - }); - const quickEditPlacement = quickEditDraft.placeholder - ? chooseGenerationPlacement({ - canvasSize, - viewport, - frame: quickEditDraft.placeholder, - layers, - generationDialogs: canvasGenerationDialogs, - }) - : quickEditDraft.placeholder; - quickEditDialogId = openCanvasGenerationDialog({ - ...quickEditDraft, - // 中文注释:视频快速编辑仍落到独立视频结果占位,图片快速编辑直接替换源图。 - placeholder: quickEditPlacement, - }); - setQuickEditPanel(null); - selectSingleLayer(null); - } else { - setQuickEditPanel({ - ...quickEditPanel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - size: quickEditSize, - prompt: normalizedPrompt, - status: 'generating', - errorMessage: undefined, - }); - } + setQuickEditPanel({ + ...quickEditPanel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + size: quickEditSize, + prompt: normalizedPrompt, + status: 'generating', + errorMessage: undefined, + }); try { const extraReferenceImageSrcs = await Promise.all( @@ -1515,105 +1459,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ resolveEditorGenerationMediaReference(reference, 'image', projectId), ), ); - const quickEditFrame = quickEditDialogId - ? getGeneratingDialogPlaceholder({ - id: quickEditDialogId, - mode: 'quick-edit', - prompt: normalizedPrompt, - status: 'generating', - }) - : undefined; - const quickEditCanvasCompletionPlaceholder = - quickEditFrame ?? - buildRightSideCanvasCompletionPlaceholder(quickEditSourceLayer, { - width: quickEditOutputSize.width, - height: quickEditOutputSize.height, - originalWidth: quickEditOutputSize.width, - originalHeight: quickEditOutputSize.height, - }); - if (isVideoQuickEditSource(quickEditSourceLayer)) { - const videoModel = DEFAULT_VIDEO_MODEL; - const videoResolution = '480p' as const; - const videoDurationSeconds = 4 as const; - const sourceVideoSrc = await resolveEditorGenerationMediaReference( - quickEditSourceLayer, - 'video', - projectId, - { allowRegisteredIds: false }, - ); - const generationInputs = buildVideoGenerationInputs( - normalizedPrompt, - [createCanvasLayerReference(quickEditSourceLayer)], - { - model: videoModel, - aspectRatio: '16:9', - resolution: videoResolution, - durationSeconds: videoDurationSeconds, - sound: 'off', - }, - ); - const generated = await runEditorGenerationWithWalletRefresh( - generateEditorVideo({ - prompt: normalizedPrompt, - model: videoModel, - aspectRatio: '16:9', - durationSeconds: videoDurationSeconds, - resolution: videoResolution, - mode: 'std', - sound: 'off', - ...(extraReferenceImageSrcs.length - ? { referenceImageSrcs: extraReferenceImageSrcs } - : {}), - referenceVideoSrcs: [sourceVideoSrc], - projectId, - generationInputs, - sourceResourceId: quickEditSourceLayer.resourceId, - assetKind: quickEditSourceLayer.assetKind, - assetFolderId, - assetLabel: quickEditResultTitle, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: quickEditResultTitle, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }), - onWalletBalanceMayHaveChanged, - ); - if ( - await applyQueuedEditorGenerationProject( - generated, - projectId, - applyProjectSnapshot, - onQueuedGenerationTask, - onWalletBalanceMayHaveChanged, - ) - ) { - return; - } - if (generated.project && applyProjectSnapshot) { - applyProjectSnapshot(generated.project); - if (generated.asset) { - upsertGeneratedAsset?.(generated.asset); - } - return; - } - addVideoResultLayer( - generated, - quickEditResultTitle, - generationInputs, - quickEditFrame, - quickEditDialogId, - { - sourceLayer: quickEditSourceLayer, - assetKind: quickEditSourceLayer.assetKind, - }, - ); - return; - } const quickEditSelectionMarks = quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id ? quickEditSelectionState.marks @@ -1700,22 +1545,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generated, quickEditSourceLayer, generationInputs, - quickEditDialogId, + undefined, quickEditResultTitle, ); } catch (error) { - if (quickEditDialogId) { - updateCanvasGenerationDialogById( - quickEditDialogId, - (currentDialog) => ({ - ...currentDialog, - prompt: normalizedPrompt, - status: 'failed', - composerOpen: false, - errorMessage: resolveImageGenerationErrorMessage(error), - }), - ); - } setQuickEditPanel({ ...quickEditPanel, aspectRatio: quickEditAspectRatio, @@ -1727,26 +1560,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }); } }, [ - addVideoResultLayer, applyQuickEditResultToSourceLayer, applyProjectSnapshot, - canvasGenerationDialogs, - canvasSize, - getGeneratingDialogPlaceholder, - layers, - openCanvasGenerationDialog, projectId, assetFolderId, - upsertGeneratedAsset, quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - selectSingleLayer, setActiveSidebarPanel, setActiveTool, setQuickEditPanel, - updateCanvasGenerationDialogById, - viewport, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 2c1c07237..0bbdb6dcb 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1558,6 +1558,28 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not open quick edit programmatically for video layers', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + + expect(screen.getByTestId('tool').textContent).toBe('select'); + expect(screen.getByTestId('selected').textContent).toBe('-'); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-'); + }); + it('calculates character animation button price from the model pricing config', () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 45ea3c89a..67341c4c6 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -3871,14 +3871,12 @@ export function useImageCanvasGenerationWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel: effectiveCharacterAnimationPanel, characterAnimationDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog, - openCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById,