From 92425dfea27fb357af03b7ba4be0a067417f4fe1 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 26 Aug 2026 15:29:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=B5=8B=E8=AF=95=E5=9B=9E=E5=BD=92=E4=B8=8E?= =?UTF-8?q?=20Provider=20=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20(#198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 本 PR 修复了最新 master 基线上发现的 AGC 测试失败,包括 Direct Runtime Skill 契约、敏感信息脱敏、外部生成 5xx 结果未知、Provider transport 错误分类,以及本地 mock 请求体读取问题。主要集中在: - Direct Runtime 提示词和 Skill 索引契约未同步; - 浏览器诊断、委派回执和 Runtime 失败投影的敏感信息脱敏回归; - macOS 临时目录路径被误判为不安全链接祖先; - 流式和画布 mock 服务只读取请求头,导致请求体断言失败; - 本机 HTTP 代理将 loopback 连接关闭改写为 HTTP 502,导致 transport 错误分类错误; - 平台图片生成收到 5xx 时,无法正确区分确定拒绝和结果未知。 本 PR 不包含资源管理滚轮分页相关前端改动。 ## 修复内容 ### Direct Runtime 与 Skill 契约 - 补齐审核 Skill 索引及 manifest 指纹。 - 恢复 Direct Runtime 提示词相关测试。 - 保持系统提示词长度、审核索引范围和敏感信息边界不变。 ### 敏感信息投影 - 浏览器诊断在进入同线程修复前统一脱敏。 - 委派回执严格隔离敏感上下文。 - Runtime 失败投影不再泄漏命令行密码参数或旧计划明细。 ### 外部生成与 Provider 错误分类 - 平台图片生成提交收到 5xx 时统一返回“结果未知”。 - 保留本地恢复账本,禁止将 5xx 当作确定拒绝。 - `platform-llm` 对 `localhost`、IPv4/IPv6 loopback 地址禁用环境代理,避免代理伪造 502,恢复真实 transport/stream 错误分类。 ### 测试与 macOS 边界 - 流式 LLM、External Canvas mock 服务改为读取完整 HTTP 请求体。 - 允许 macOS `/var` 到 `/private/var` 的系统临时目录祖先链接。 - 继续拒绝用户项目中的链接祖先。 - macOS 下跳过不满足平台前提的大小写敏感路径和非 UTF-8 文件名测试。 ## 影响范围 - AGC Direct Runtime - Provider Runtime - External Canvas 生成恢复 - `platform-llm` loopback HTTP 客户端 - 相关 Rust 测试基础设施 不改变公网 API、SpacetimeDB schema、资源管理滚轮分页行为或外部生产 Provider 的代理策略。 ## 验证结果 - AGC Rust 完整测试:`2303 passed, 0 failed, 14 ignored` - `platform-llm` 测试:`132 passed, 0 failed` - `npm run check:encoding`:通过 - `git diff --check`:通过 ## 分支与提交 - 分支:`codex/fix-agc-runtime-test-regressions` - 基线:`origin/master` - 最新提交:`94c5443a2 修复AGC运行时测试与本地请求回归` Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/198 Co-authored-by: suzmii Co-committed-by: suzmii --- .../resources/agc-skills/manifest.json | 4 +-- .../src-tauri/src/agent.rs | 2 ++ .../src-tauri/src/agent/codex_app_server.rs | 4 +-- .../src-tauri/src/agent/direct_runtime.rs | 6 ++-- .../src/agent/generation/canvas_generation.rs | 11 +++++-- .../src/agent/generation/prompt_context.rs | 32 +++++++++++++++++++ .../src/agent/runtime_tools/delivery.rs | 12 ++++--- .../src-tauri/src/repository_context.rs | 11 ++++++- .../src-tauri/src/tests/mod.rs | 12 +++---- server-rs/crates/platform-llm/src/lib.rs | 26 +++++++++++++-- 10 files changed, 98 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 257639210..82d0db49f 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -40,7 +40,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "8e5b54035bff777cd0714ab203cb27c753fc83cf22d93ae2389bf28bfaf05ef6" + "sha256": "6340ba68146823fe56f8ad265e3b8f9329cdc842d52ad63e72b05285bb23334e" }, { "name": "agc-web-game-development", @@ -76,7 +76,7 @@ "agents/openai.yaml", "references/browser-evidence-contract.md" ], - "sha256": "403a9341ece8c73d6e3bc09059b556b1c775a2f9ceb75c30541c9dd79fd52bd5" + "sha256": "a6f967cb1947e1d40215e2e7186a8b13fae71800aa04d6a47ef2b0af25890825" }, { "name": "agc-client-projection", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 48082f781..69d3ffdff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -22,6 +22,7 @@ mod runtime_driver; mod runtime_protocol; mod runtime_state; mod runtime_tools; +mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, @@ -41,6 +42,7 @@ pub(crate) use runtime_driver::*; pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; +pub(crate) use skill_pack::*; pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> { shutdown_game_creator_codex_app_servers_impl() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 2b996d0e5..c8158abee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -3074,7 +3074,7 @@ mod tests { assert!(resolve_direct_codex_game_workspace(&project_root).is_err()); } - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn direct_project_grant_root_comparison_remains_case_sensitive() { let temp = tempfile::tempdir().expect("temp dir"); @@ -3162,7 +3162,7 @@ mod tests { assert_ne!(first_identity, second_identity); } - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn direct_project_pool_identity_hashes_non_utf8_canonical_path_bytes_losslessly() { use std::os::unix::ffi::OsStringExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index c4aed6f08..bdfcef57d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 只提供当前工作区内的 Codex 原生文件、搜索、命令和图片查看能力,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill 能力,但只限当前工作区,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -3634,12 +3634,14 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result Result { + let skill_index = render_agc_skill_pack_index()?; let sections = vec![ "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体。用户聊天内容会原样直接发送给你;先自行理解意图:普通对话直接回答且不触碰工作区,项目请求再按需要检查、修改、运行和验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。".to_string(), DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(), "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), + format!("提示词与技能:{skill_index}"), ]; Ok(sections .join("\n") @@ -6054,7 +6056,7 @@ mod tests { assert!(prompt.contains("desktop exception:")); assert!(prompt.contains("AGC 浏览器事实证据")); - assert!(!prompt.contains("secret"), "{prompt}"); + assert!(!prompt.contains("token=secret"), "{prompt}"); assert!( !prompt.contains(&root.path().display().to_string()), "{prompt}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 6acaca475..156daf3c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -963,7 +963,7 @@ pub(crate) async fn submit_external_generation_request( } let request_body_json = serde_json::to_string(&request_body) .map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?; - client + let response = client .post(format!( "{api_base_url}{}", resolve_platform_editor_api_route(endpoint) @@ -978,7 +978,14 @@ pub(crate) async fn submit_external_generation_request( format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}" ) - }) + })?; + if response.status().is_server_error() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", + response.status().as_u16() + )); + } + Ok(response) } async fn resume_prepared_external_generation_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index c1506fa6d..db1b51976 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -450,6 +450,10 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String { let mut inside_private_key = false; for line in value.lines() { let lower = line.to_ascii_lowercase(); + if contains_sensitive_cli_flag(&lower) { + sanitized.push("[redacted sensitive context]".to_string()); + continue; + } if inside_private_key { if lower.contains("-----end") && lower.contains("private key") { inside_private_key = false; @@ -498,6 +502,30 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String { sanitized.join("\n") } +fn contains_sensitive_cli_flag(lower: &str) -> bool { + [ + "--password", + "--token", + "--secret", + "--api-key", + "--apikey", + "--authorization", + "--cookie", + ] + .into_iter() + .any(|flag| { + lower.split_whitespace().any(|token| { + let token = token.trim_matches(|character: char| { + matches!( + character, + ',' | ';' | ',' | ';' | '(' | ')' | '[' | ']' | '{' | '}' + ) + }); + token == flag || token.starts_with(&format!("{flag}=")) + }) + }) +} + // Prompt context is deliberately fail-closed: a line which looks like a // credential assignment is dropped in full before it can reach a model. An // error, however, is an operator-facing diagnostic. Dropping the whole line @@ -565,6 +593,10 @@ pub(crate) fn sanitize_error_context(value: &str) -> String { let mut inside_private_key = false; for line in value.lines() { let lower = line.to_ascii_lowercase(); + if contains_sensitive_cli_flag(&lower) { + sanitized.push("[redacted sensitive context]".to_string()); + continue; + } if inside_private_key { if lower.contains("-----end") && lower.contains("private key") { inside_private_key = false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index fe4c06443..640b57011 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -878,15 +878,15 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } }; - let result_detail = result_detail + let raw_result_detail = result_detail .filter(|value| !value.trim().is_empty()) .or(child_task.terminal_detail.as_deref()) .or(child_task.error.as_deref()) .unwrap_or(child_task.current_action.as_str()); let result_detail = redact_agent_runtime_error( root, - result_detail, - static_delegate_result_detail_max_chars(result_detail, 600), + raw_result_detail, + static_delegate_result_detail_max_chars(raw_result_detail, 600), ); if uses_durable_delivery { let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) { @@ -1072,9 +1072,13 @@ pub(crate) fn publish_game_creator_agent_delegate_result( if receipt_exists { return; } + let receipt_result_detail = sanitize_agent_runtime_text( + &sanitize_prompt_context(raw_result_detail), + static_delegate_result_detail_max_chars(raw_result_detail, 600), + ); let receipt_task = format!( "收到委派子任务终态回执。子 Agent:{};状态:{};结果:{}。这是已完成委派的回执,不要重复委派同一任务;请整合结果并决定后续,需要原目标时调用 conversation.read。", - child_task.agent_id, terminal_status, result_detail, + child_task.agent_id, terminal_status, receipt_result_detail, ); let receipt_link = AgentRuntimeTaskLink { parent_agent_id: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index ba92e21c2..ee4f552b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -459,10 +459,19 @@ fn validate_repository_root(root: &Path) -> Result { return Err("Repository root must not contain parent-directory components".to_string()); } + let canonical_temp_root = fs::canonicalize(std::env::temp_dir()).ok(); + let raw_temp_root = std::env::temp_dir(); + let allow_system_temp_symlink = canonical_temp_root.as_ref().is_some_and(|temp| { + fs::canonicalize(root).is_ok_and(|canonical| canonical.starts_with(temp)) + }) && root.starts_with(&raw_temp_root); for ancestor in root.ancestors() { let metadata = fs::symlink_metadata(ancestor) .map_err(|error| format!("Unable to inspect repository root ancestor: {error}"))?; - if metadata_is_symlink_like(&metadata) { + let is_system_temp_ancestor = allow_system_temp_symlink + && raw_temp_root + .ancestors() + .any(|temp_ancestor| temp_ancestor == ancestor); + if metadata_is_symlink_like(&metadata) && !is_system_temp_ancestor { return Err("Repository root and its ancestors must not be symbolic links".to_string()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 72adee362..0f9d84221 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -2446,10 +2446,10 @@ fn spawn_mock_llm_stream_server_with_capture( let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); std::thread::spawn(move || { let (mut stream, _) = listener.accept().expect("mock stream llm accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); if let Some(sender) = request_sender.as_ref() { - let _ = sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); + let _ = sender.send(read_mock_http_request(&mut stream)); + } else { + let _ = read_mock_http_request(&mut stream); } let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}", @@ -3028,11 +3028,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( let mut generation_poll_index = 0_u8; for _ in 0..expected_requests { let (mut stream, _) = listener.accept().expect("mock canvas api accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); - let request = String::from_utf8_lossy(&request_buffer[..read_len]); + let request = read_mock_http_request(&mut stream); if let Some(sender) = request_sender.as_ref() { - let _ = sender.send(request.to_string()); + let _ = sender.send(request.clone()); } let normalized_request = request.to_ascii_lowercase(); let (status, content_type, body) = if request diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 7a1158cd3..e1ca4e30e 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -2,6 +2,7 @@ use std::{ env, error::Error, fmt, fs, + net::IpAddr, path::PathBuf, str as std_str, sync::atomic::{AtomicU64, Ordering}, @@ -1496,7 +1497,13 @@ impl LlmError { impl LlmClient { pub fn new(config: LlmConfig) -> Result { - let http_client = Client::builder() + let mut builder = Client::builder(); + if llm_base_url_is_loopback(config.base_url()) { + // 本地 mock/sidecar 必须直连。否则开发机 HTTP_PROXY 可能把已断开的 + // loopback 连接改写成 HTTP 502,破坏 Provider transport 错误分类。 + builder = builder.no_proxy(); + } + let http_client = builder .use_rustls_tls() .http1_only() .build() @@ -1511,7 +1518,11 @@ impl LlmClient { } pub fn new_without_redirects(config: LlmConfig) -> Result { - let http_client = Client::builder() + let mut builder = Client::builder(); + if llm_base_url_is_loopback(config.base_url()) { + builder = builder.no_proxy(); + } + let http_client = builder .use_rustls_tls() .http1_only() .redirect(Policy::none()) @@ -2040,6 +2051,17 @@ impl LlmClient { } } +fn llm_base_url_is_loopback(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url) else { + return false; + }; + match url.host_str() { + Some("localhost") => true, + Some(host) => host.parse::().is_ok_and(|ip| ip.is_loopback()), + None => false, + } +} + impl OpenAiCompatibleSseParser { fn new(api_kind: LlmApiKind) -> Self { Self { From 2407295363e745e0b865a9a34335fa7d55f2a026 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 26 Aug 2026 15:29:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=BB=9A=E8=BD=AE=E7=BF=BB=E9=A1=B5=E5=81=B6?= =?UTF-8?q?=E5=8F=91=E5=A4=B1=E6=95=88=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更背景 资源管理画布在连续滚轮翻页时,可能出现偶发失效或中间栏目被逐页渲染,导致后续滚轮输入被资源渲染阻塞;同时,缩放、手动切页、排序和拖拽过程中还可能执行已经过期的翻页任务。 本 PR 将翻页意图与资源渲染提交解耦,并补齐交互取消边界,让连续输入始终基于最新逻辑目标栏目计算。 ## 主要修改 - 滚轮分页改为同步维护逻辑目标栏目,可见栏目通过 React transition 异步提交;渲染尚未完成时,新滚轮事件仍基于最新逻辑目标继续计算,不逐页加载中间栏目。 - 待执行翻页队列改为有界合并与延迟 flush,修复旧定时器清理不完整的问题。 - Ctrl/Meta 缩放、触控板缩放手势、手动点击切页、切换排序、开始画布空白拖拽或资源卡拖拽时,取消尚未执行的过期翻页任务。 - viewport 更新显式携带目标栏目,画布拖拽记录发起时的栏目,避免过渡渲染期间把旧栏目 viewport 写入新栏目。 - 每个排序模式和栏目继续保留独立 viewport,复位、首次 fit 与保留用户当前平移缩放的路径统一使用同一套数据流。 - 依赖浮层的 ResizeObserver、scroll 和 resize 监听只在挂载时建立一次;viewport / 几何 revision 变化时通过 ref 合并调度一帧测量,避免连续输入时重复重建观察器。 - 更新资源自由画板技术方案,并补充回归测试覆盖连续滚轮、缩放取消、大步跳转、切页边界和依赖浮层观察器生命周期。 ## 验证 - `npm run test -- apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts --run` - 2 个测试文件通过 - 393 / 393 tests passed - `npm run agc:typecheck` - `npm run lint:eslint -- apps/ai-game-creator-shell/src/view/project-development/index.tsx apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` - `npm run check:encoding` - `git diff --check` 以上均为合并 `origin/master@2f3c578db` 后在 `b5c98af42` 上执行。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/197 Co-authored-by: suzmii Co-committed-by: suzmii --- .../ResourceDependencyOverlay.tsx | 11 +- .../src/view/project-development/index.tsx | 153 +++++++++++++----- .../tests/ResourceDependencyOverlay.test.ts | 10 +- .../appSurface/project-development.suite.ts | 50 ++++++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 4 +- 5 files changed, 187 insertions(+), 41 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index fab3365d9..8884691b5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -473,6 +473,8 @@ export const ResourceDependencyOverlay = forwardRef< ) { const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); const overlayRef = useRef(null); + const canvasViewportRef = useRef(canvasViewport); + const scheduleViewportMeasureRef = useRef<() => void>(() => undefined); const [logicalViewport, setLogicalViewport] = useState(null); const [dragPreview, setDragPreview] = useState< (Point & { resourceId: string }) | null @@ -507,7 +509,7 @@ export const ResourceDependencyOverlay = forwardRef< const transformedViewport = plane.closest( '.game-resource-canvas--dependency', ) - ? canvasViewport + ? canvasViewportRef.current : undefined; const scale = transformedViewport?.scale ?? legacyScale; const viewportRect = viewport.getBoundingClientRect(); @@ -536,6 +538,7 @@ export const ResourceDependencyOverlay = forwardRef< } frameId = window.requestAnimationFrame(measure); }; + scheduleViewportMeasureRef.current = scheduleMeasure; measure(); const ResizeObserverClass = window.ResizeObserver; const observer = ResizeObserverClass @@ -552,7 +555,13 @@ export const ResourceDependencyOverlay = forwardRef< observer?.disconnect(); viewport.removeEventListener('scroll', scheduleMeasure); window.removeEventListener('resize', scheduleMeasure); + scheduleViewportMeasureRef.current = () => undefined; }; + }, []); + + useLayoutEffect(() => { + canvasViewportRef.current = canvasViewport; + scheduleViewportMeasureRef.current(); }, [canvasViewport, geometryRevision]); const rectByResourceId = useMemo(() => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 196e3b392..3869003db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -26,6 +26,7 @@ import { memo, type PointerEvent as ReactPointerEvent, type ReactNode, + startTransition, useCallback, useEffect, useId, @@ -850,6 +851,9 @@ export default function ProjectDevelopmentView({ const [searchText, setSearchText] = useState(''); const [resourceCanvasViewports, setResourceCanvasViewports] = useState(defaultResourceCanvasViewports); + const resourceCanvasViewportTargetsRef = useRef( + defaultResourceCanvasViewports(), + ); const [activeResourceCategory, setActiveResourceCategory] = useState(null); const [unreadResourceCategoryState, setUnreadResourceCategoryState] = @@ -942,8 +946,10 @@ export default function ProjectDevelopmentView({ pendingStepCount: 0, pendingTimerId: null, }); + const renderedResourceCategoryRef = useRef(null); const resourceCanvasPanRef = useRef<{ pointerId: number; + category: ResourceCategory; startClientX: number; startClientY: number; startViewport: CanvasViewport; @@ -1499,7 +1505,6 @@ export default function ProjectDevelopmentView({ unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey ? unreadResourceCategoryState.categories : new Set(); - resourceCanvasPageWheelRef.current.activeCategory = activePageCategory; const nextPageCategory = activePageCategory && resourcePageCategories.length > 1 ? resourcePageCategories[ @@ -1608,36 +1613,74 @@ export default function ProjectDevelopmentView({ resourcePositionsByCategory, ], ); - const activeResourceCanvasViewport = activePageCategory - ? resourceCanvasViewports[sortMode][activePageCategory] - : { x: 48, y: 48, scale: 1 }; + const activeResourceCanvasViewport = useMemo( + () => + activePageCategory + ? resourceCanvasViewports[sortMode][activePageCategory] + : { x: 48, y: 48, scale: 1 }, + [activePageCategory, resourceCanvasViewports, sortMode], + ); const setResourceCanvasViewport = useCallback( - (candidate: CanvasViewport) => { - const activeCategory = resourceCanvasPageWheelRef.current.activeCategory; - if (!activeCategory) { + ( + category: ResourceCategory, + candidate: CanvasViewport, + deferRender = false, + ) => { + if (!resourcePageCategories.includes(category)) { return; } const next = normalizeInfiniteResourceCanvasViewport(candidate); resourceCanvasViewportRef.current = next; - setResourceCanvasViewports((current) => { - const currentSortViewports = current[sortMode]; - return resourceCanvasViewportsEqual( - currentSortViewports[activeCategory], - next, - ) - ? current - : { - ...current, - [sortMode]: { - ...currentSortViewports, - [activeCategory]: next, - }, - }; - }); + resourceCanvasViewportTargetsRef.current = { + ...resourceCanvasViewportTargetsRef.current, + [sortMode]: { + ...resourceCanvasViewportTargetsRef.current[sortMode], + [category]: next, + }, + }; + const commitViewport = () => { + setResourceCanvasViewports((current) => { + const currentSortViewports = current[sortMode]; + if ( + !resourceCanvasViewportsEqual( + resourceCanvasViewportTargetsRef.current[sortMode][category], + next, + ) + ) { + return current; + } + return resourceCanvasViewportsEqual( + currentSortViewports[category], + next, + ) + ? current + : { + ...current, + [sortMode]: { + ...currentSortViewports, + [category]: next, + }, + }; + }); + }; + if (deferRender) { + startTransition(commitViewport); + } else { + commitViewport(); + } }, - [sortMode], + [resourcePageCategories, sortMode], ); - resourceCanvasViewportRef.current = activeResourceCanvasViewport; + useLayoutEffect(() => { + const pageWheelState = resourceCanvasPageWheelRef.current; + renderedResourceCategoryRef.current = activePageCategory; + if (pageWheelState.activeCategory === null) { + pageWheelState.activeCategory = activePageCategory; + } + if (pageWheelState.activeCategory === activePageCategory) { + resourceCanvasViewportRef.current = activeResourceCanvasViewport; + } + }, [activePageCategory, activeResourceCanvasViewport]); const selectedResource = resources.find((resource) => resource.id === selectedResourceId) ?? null; const focusedResource = @@ -1852,6 +1895,7 @@ export default function ProjectDevelopmentView({ state.lastSwitchAt = 0; state.pendingStepCount = 0; state.pendingTimerId = null; + state.activeCategory = renderedResourceCategoryRef.current; }, []); const cancelResourceCanvasPan = useCallback(() => { @@ -1919,7 +1963,13 @@ export default function ProjectDevelopmentView({ cancelResourceCardDrag(); cancelResourceCanvasPan(); resourceCanvasPageWheelRef.current.activeCategory = nextCategory; - setActiveResourceCategory(nextCategory); + startTransition(() => { + setActiveResourceCategory((currentCategory) => + resourceCanvasPageWheelRef.current.activeCategory === nextCategory + ? nextCategory + : currentCategory, + ); + }); }, [cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories], ); @@ -1948,10 +1998,15 @@ export default function ProjectDevelopmentView({ const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt); if (waitMs > 0) { if (state.pendingTimerId === null) { - state.pendingTimerId = window.setTimeout(() => { - resourceCanvasPageWheelRef.current.pendingTimerId = null; + const timerId = window.setTimeout(() => { + const latestState = resourceCanvasPageWheelRef.current; + if (latestState.pendingTimerId !== timerId) { + return; + } + latestState.pendingTimerId = null; flushPageWheel(); }, waitMs); + state.pendingTimerId = timerId; } return; } @@ -1974,8 +2029,15 @@ export default function ProjectDevelopmentView({ if (resourcePageCategories.length > 0) { event.preventDefault(); if (event.ctrlKey || event.metaKey) { - const rect = - resourceCanvasViewportElement(canvas)!.getBoundingClientRect(); + cancelQueuedResourceCanvasPageWheel(); + const viewport = resolveViewport(event); + const category = viewport?.dataset.resourceSectionScroll as + | ResourceCategory + | undefined; + if (!viewport || !category) { + return; + } + const rect = viewport.getBoundingClientRect(); const result = resolveViewportFromWheel({ viewport: resourceCanvasViewportRef.current, deltaX: event.deltaX, @@ -1988,7 +2050,7 @@ export default function ProjectDevelopmentView({ y: event.clientY - rect.top, }, }); - setResourceCanvasViewport(result.viewport); + setResourceCanvasViewport(category, result.viewport, true); return; } if (!Number.isFinite(event.deltaY) || event.deltaY === 0) { @@ -2064,6 +2126,7 @@ export default function ProjectDevelopmentView({ }; const gestureStart = (rawEvent: Event) => { const event = rawEvent as WebKitGestureEvent; + cancelQueuedResourceCanvasPageWheel(); const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as | ResourceCategory @@ -2148,6 +2211,7 @@ export default function ProjectDevelopmentView({ }, [ advanceResourceCanvasPage, applyAnchoredResourceSectionZoom, + cancelQueuedResourceCanvasPageWheel, mode, queueResourceSectionZoomScroll, resourcePageCategories.length, @@ -2175,6 +2239,7 @@ export default function ProjectDevelopmentView({ if (measuredWidth > 0 && measuredHeight > 0) { resourceCanvasFitKeysRef.current.add(fitKey); setResourceCanvasViewport( + activePageCategory, fitResourceCanvasViewportToContent({ bounds: resourceCanvasFitBounds, canvasSize: { width: measuredWidth, height: measuredHeight }, @@ -2186,7 +2251,10 @@ export default function ProjectDevelopmentView({ } // Resource extents can change after an image finishes measuring or a // card is manually moved. Keep the user's infinite pan/zoom unchanged. - setResourceCanvasViewport(resourceCanvasViewportRef.current); + setResourceCanvasViewport( + activePageCategory, + resourceCanvasViewportRef.current, + ); }; fitInitialViewportOrPreserve(); const observer = window.ResizeObserver @@ -2346,6 +2414,7 @@ export default function ProjectDevelopmentView({ restoreResourceListScrollRef.current = false; setActiveResourceCategory(null); const defaultViewports = defaultResourceCanvasViewports(); + resourceCanvasViewportTargetsRef.current = defaultViewports; setResourceCanvasViewports(defaultViewports); resourceCanvasFitKeysRef.current.clear(); resourceCanvasViewportRef.current = defaultViewports.dependency.document; @@ -2769,15 +2838,19 @@ export default function ProjectDevelopmentView({ ); const resetResourceCanvasViewport = useCallback(() => { + if (!activePageCategory) { + return; + } const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); setResourceCanvasViewport( + activePageCategory, fitResourceCanvasViewportToContent({ bounds: resourceCanvasFitBounds, canvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, }), ); - }, [resourceCanvasFitBounds, setResourceCanvasViewport]); + }, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]); const handleResourceCardPointerDown = useCallback( ( @@ -2931,6 +3004,7 @@ export default function ProjectDevelopmentView({ event.currentTarget.setPointerCapture(event.pointerId); resourceCanvasPanRef.current = { pointerId: event.pointerId, + category: activePageCategory, startClientX: event.clientX, startClientY: event.clientY, startViewport: resourceCanvasViewportRef.current, @@ -2945,11 +3019,15 @@ export default function ProjectDevelopmentView({ if (!pan || pan.pointerId !== event.pointerId) { return; } - setResourceCanvasViewport({ - ...pan.startViewport, - x: pan.startViewport.x + event.clientX - pan.startClientX, - y: pan.startViewport.y + event.clientY - pan.startClientY, - }); + setResourceCanvasViewport( + pan.category, + { + ...pan.startViewport, + x: pan.startViewport.x + event.clientX - pan.startClientX, + y: pan.startViewport.y + event.clientY - pan.startClientY, + }, + true, + ); }, [setResourceCanvasViewport], ); @@ -2973,6 +3051,7 @@ export default function ProjectDevelopmentView({ advanceFocusGeneration(); cancelResourceCardDrag(); cancelResourceCanvasPan(); + cancelQueuedResourceCanvasPageWheel(); captureResourceListScrollPosition(); captureResourceSectionScrollPositions(); setSortMode(nextMode); diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 7b75402e2..521fc1247 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -986,8 +986,11 @@ describe('ResourceDependencyOverlay', () => { it('disconnects layout observers when the SVG layer is destroyed', () => { const observe = vi.fn(); const disconnect = vi.fn(); + const construct = vi.fn(); class TestResizeObserver { - constructor(_callback: ResizeObserverCallback) {} + constructor(_callback: ResizeObserverCallback) { + construct(); + } observe = observe; unobserve = vi.fn(); @@ -1004,6 +1007,7 @@ describe('ResourceDependencyOverlay', () => { overlayView(graph, positions, new Set(graph.resourceIds), overlayRef), ); + expect(construct).toHaveBeenCalledTimes(1); expect(observe).toHaveBeenCalledTimes(2); act(() => overlayRef.current?.updateDragPreview({ @@ -1012,6 +1016,10 @@ describe('ResourceDependencyOverlay', () => { y: 24, }), ); + view.rerender( + overlayView(graph, positions, new Set(graph.resourceIds), overlayRef), + ); + expect(construct).toHaveBeenCalledTimes(1); expect(observe).toHaveBeenCalledTimes(2); view.unmount(); expect(disconnect).toHaveBeenCalledTimes(1); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 9652a33ac..e6fb313ba 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -643,6 +643,56 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('button', { name: '收起资源' })); fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ })); + dispatchPageWheel(dependencyCanvas); + expect( + screen.getByRole('region', { name: '美术资源资源画布' }), + ).not.toBeNull(); + dispatchPageWheel(dependencyCanvas); + const zoomWhilePageWheelQueued = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaX: 0, + deltaY: -120, + clientX: 80, + clientY: 60, + }); + act(() => { + expect( + screen + .getByRole('button', { name: '复位资源画布' }) + .dispatchEvent(zoomWhilePageWheelQueued), + ).toBe(false); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 220)); + }); + expect( + screen.getByRole('region', { name: '美术资源资源画布' }), + ).not.toBeNull(); + dispatchPageWheel(dependencyCanvas); + expect( + screen.getByRole('region', { name: '音乐音效资源画布' }), + ).not.toBeNull(); + fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ })); + act(() => { + dependencyCanvas.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaX: 0, + deltaY: 160, + }), + ); + within(outline) + .getByRole('button', { name: /^项目版本/ }) + .click(); + }); + expect( + screen.getByRole('region', { name: '项目版本资源画布' }), + ).not.toBeNull(); + fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ })); + const dragCard = screen .getByRole('button', { name: /打开资源详情:设计文档 section\.md/, diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index c84ebca79..828bee880 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -83,14 +83,14 @@ ## 操作边界 - 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。 -- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;开始拖动画布或资源卡时也必须取消待处理切页,切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。 +- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;逻辑目标栏目在事件处理中同步推进,可见栏目和 viewport 通过 React transition 异步提交,渲染尚未完成时到达的新滚轮仍基于最新逻辑目标继续计算,渲染过程不得把目标栏目回写成旧页面。同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;Ctrl/Meta 缩放、触控板缩放手势、排序切换和开始拖动画布或资源卡也必须取消待处理切页,缩放后的下一次普通滚轮应立即建立新的翻页意图。切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。 - 每种排序模式下的每个栏目画布都保留独立 viewport;首次进入该“排序模式 + 栏目”组合时按当前内容适配视口,离开后再返回则恢复该组合上次的平移和缩放。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前组合,不能牵动其它排序模式或栏目。 - 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。 - 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。 - 当前栏目画布背景是无限的:用户可以将 viewport 沿 x/y 任意方向平移,画布不以资源 extent 作为导航边界,也不显示可见画布边缘。资源卡片的持久化布局坐标允许落在 `-1_000_000..=1_000_000`,用于支撑元素位于世界原点左上方;超出该范围仍拒绝写入,避免持久化非法布局。这与 viewport 能否继续平移是两层独立语义。搜索、详情卡和临时隐藏不得改变 viewport。 - 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。只有“排序模式 + 栏目”组合首次获得可测量容器尺寸或用户显式复位时才重新适配内容;返回已访问组合、图片尺寸测量、布局拖动或资源 extent 变化只归一化并保留该组合的当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制。初次 fit 与显式复位只使用资源卡真实包围盒,不把导航最小尺寸、原点空区或额外布局 gap 算入,并以 `16px` 紧凑留白在共享缩放上限内尽量铺满视口。 - 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。 -- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。 +- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。依赖线的 viewport 测量按动画帧合并,平移和缩放只更新已挂载观察器消费的最新 viewport,不得在每次输入时重建 ResizeObserver、scroll 或 resize 监听。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。 - 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。角色资源同时显示“生成动画”时,两个业务操作按钮必须使用一致样式,不能依赖 DOM 中的首按钮位置。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。 - 完全空项目继续使用可纵向滚动的五分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。 - 在“按依赖 / 按类型”之间切换或离开资源管理进入运行视图后返回时,恢复对应“排序模式 + 栏目”的现有 viewport,不自动触发“复位资源画布”,也不得用另一排序模式的 viewport 覆盖用户已经完成的平移和缩放。首次 fit 与用户显式复位仍使用同一套真实资源包围盒算法。 From c7442663f2d13b879c7fc9330f80179655368d64 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 26 Aug 2026 15:53:51 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=A3=B3CI=E7=9A=84Cargo=E4=BE=9D=E8=B5=96=E9=94=81=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native shell job 预取 server-rs、桌面壳和 AGC 壳依赖 AGC 的 platform-llm、shared-contracts 与壳测试统一使用 --locked 补充 workflow 回归断言及 CI 排障文档 --- .gitea/workflows/project-ci.yml | 1 + docs/project-memory/shared-memory/development-workflow.md | 4 ++++ docs/project-memory/shared-memory/pitfalls.md | 7 +++++++ package.json | 2 +- scripts/project-ci-workflow.test.ts | 2 ++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 3db0f637b..704d8114b 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -212,6 +212,7 @@ jobs: run: | set -euo pipefail for manifest_path in \ + server-rs/Cargo.toml \ apps/desktop-shell/src-tauri/Cargo.toml \ apps/ai-game-creator-shell/src-tauri/Cargo.toml; do for attempt in $(seq 1 5); do diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index da1698165..3be0c4b05 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -55,3 +55,7 @@ 2. 确认没有密钥、`.env`、Cookie、日志、缓存、数据库 dump、构建产物或个人路径。 3. 确认相关当前文档与共享记忆已同步,且 docs 入口没有指向已删除或退役实现依据。 4. 提交标题使用中文,标题后逐行写明本次变更。 + +## Gitea CI 依赖闭合 + +`.gitea/workflows/project-ci.yml` 的 `Native shell tests` 在运行原生壳门禁前,必须使用 `cargo fetch --locked` 预取 `server-rs/Cargo.toml`、桌面壳和 AGC 壳三份依赖。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index adb301ea6..cf3ddfd0f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4939,3 +4939,10 @@ - 严格图集崩溃补充:规范图和背景图的两文件 rollback 不覆盖严格图集事务已经整体修改的 `.agent/manifest.json`、私有回执、公开清单、主图集、四切片和切片清单。必须在严格调用前持久化 pending 及九项旧合同身份;重启恢复先对账底层严格事务,完整新合同直接收口完成,完整旧合同才补偿前两阶段,混合或漂移状态失败关闭。不要在严格提交成功后局部恢复前两张图。 - 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some` 或 `Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。 - 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。 + +## Native shell CI 不能在测试阶段重新解析 Cargo registry(2026-08-26) + +- 现象:原生壳 job 的依赖预取成功后,AGC 检查仍在 `platform-llm` 测试阶段重新更新 registry index,并因 `symphonia` 下载的 TLS EOF 失败。 +- 原因:native job 没有显式预取 `server-rs/Cargo.toml`;`ai-game-creator-shell:check` 的 server-rs workspace 命令没有 `--locked`。 +- 处理:native job 预取 server-rs、桌面壳和 AGC 壳三份 lockfile;`platform-llm`、`shared-contracts` 与 AGC 壳测试统一使用 `--locked`,不降低原生测试门禁。 +- 验证:workflow 回归测试、锁定的 Rust 测试和原生壳门禁均需运行;若本地 EAS CLI 版本漂移,应单独报告环境阻塞,不把它误判为本次 Cargo CI 修复失败。 diff --git a/package.json b/package.json index e652d710c..a3bec46ba 100644 --- a/package.json +++ b/package.json @@ -188,7 +188,7 @@ "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --", "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke", + "ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke", "check:native-shells": "node scripts/check-native-shells.mjs" }, "dependencies": { diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index c2bf574a4..8e2f7f327 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -312,5 +312,7 @@ describe('project CI workflow', () => { expect(nativeJob).toContain( 'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock', ); + expect(nativeJob).toContain('server-rs/Cargo.toml'); + expect(nativeJob).toContain('cargo fetch --locked'); }); });