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 5a1d30e9f..31dc968e1 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 @@ -570,7 +570,7 @@ struct CodexAppServerInner { workspace_mode: CodexAppServerWorkspaceMode, _provider_proxy: Option, tool_bridge: Option, - _skill_root: Option, + _skill_roots: Option>, } #[derive(Clone)] @@ -615,6 +615,12 @@ fn game_creator_codex_app_server_pool_key( } else { "disabled".to_string() }; + let client_skill_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + crate::client_extensions::enabled_client_skill_fingerprint() + .unwrap_or_else(|_| "invalid-client-skills".to_string()) + } else { + "disabled".to_string() + }; let stable = serde_json::json!({ "credentialFingerprint": credential_fingerprint, "baseUrl": llm.base_url, @@ -628,6 +634,7 @@ fn game_creator_codex_app_server_pool_key( }, "workspaceMode": workspace_mode.pool_identity(), "skillPackIdentity": skill_pack_identity, + "clientSkillIdentity": client_skill_identity, "controlledWebSearch": llm.web_search_enabled, "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, "providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, @@ -1513,10 +1520,18 @@ impl CodexAppServerConnection { )) })?; } - let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let skill_roots = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { install_agc_skill_pack(&isolated_os_home) .map_err(platform_llm::LlmError::InvalidConfig)?; - Some(isolated_os_home.join(".agents").join("skills")) + let bundled_root = isolated_os_home.join(".agents").join("skills"); + let mut roots = vec![bundled_root]; + if let Some(client_root) = + crate::client_extensions::prepare_enabled_client_skill_root(&isolated_os_home) + .map_err(platform_llm::LlmError::InvalidConfig)? + { + roots.push(client_root); + } + Some(roots) } else { None }; @@ -1632,7 +1647,7 @@ impl CodexAppServerConnection { workspace_mode, _provider_proxy: provider_proxy, tool_bridge, - _skill_root: skill_root, + _skill_roots: skill_roots, }); tokio::spawn(read_game_creator_codex_app_server_stdout( Arc::downgrade(&inner), @@ -1664,11 +1679,11 @@ impl CodexAppServerConnection { if let Some(reason) = remote_control_disable_reason { eprintln!("agent.codex_app_server.remote_control disabled reason={reason}"); } - if let Some(skill_root) = connection.inner._skill_root.as_ref() { + if let Some(skill_roots) = connection.inner._skill_roots.as_ref() { connection .request( "skills/extraRoots/set", - serde_json::json!({ "extraRoots": [skill_root] }), + serde_json::json!({ "extraRoots": skill_roots }), ) .await .map_err(platform_llm::LlmError::Transport)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 779ba5780..990bc7e63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -487,6 +487,165 @@ fn stored_item_view( } } +fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String { + let mut entries = index + .items + .iter() + .filter(|item| item.extension_type == "skill" && item.enabled) + .map(|item| { + ( + item.name.clone(), + item.source_relative_path.clone(), + item.fingerprint.clone(), + ) + }) + .collect::>(); + entries.sort(); + + let mut digest = Sha256::new(); + digest.update(b"direct-project-client-skills.v1\0"); + for (name, source_relative_path, fingerprint) in entries { + for value in [name, source_relative_path, fingerprint] { + digest.update((value.len() as u64).to_le_bytes()); + digest.update(value.as_bytes()); + } + } + format!("{:x}", digest.finalize()) +} + +pub(crate) fn enabled_client_skill_fingerprint() -> Result { + let root = extensions_root()?; + let index = read_index(&root)?; + Ok(client_skill_set_fingerprint(&index)) +} + +fn rewrite_skill_name(content: &[u8], name: &str) -> Vec { + let Ok(text) = std::str::from_utf8(content) else { + return content.to_vec(); + }; + let mut lines = text.split_inclusive('\n'); + let Some(first) = lines.next() else { + return content.to_vec(); + }; + if first.trim_matches(['\r', '\n', ' ', '\t']) != "---" { + return content.to_vec(); + } + + let newline = if first.ends_with("\r\n") { + "\r\n" + } else if first.ends_with('\n') { + "\n" + } else { + "\n" + }; + let mut output = String::with_capacity(text.len() + name.len() + 16); + output.push_str(first); + let mut found_name = false; + let mut in_frontmatter = true; + for line in lines { + let content_end = line.trim_end_matches(['\r', '\n']); + let line_ending = &line[content_end.len()..]; + if in_frontmatter && content_end.trim() == "---" { + if !found_name { + output.push_str("name: "); + output.push_str(name); + output.push_str(newline); + } + in_frontmatter = false; + output.push_str(line); + continue; + } + if in_frontmatter { + let trimmed = content_end.trim_start(); + if trimmed.starts_with("name:") { + let indent_len = content_end.len() - trimmed.len(); + output.push_str(&content_end[..indent_len]); + output.push_str("name: "); + output.push_str(name); + output.push_str(line_ending); + found_name = true; + continue; + } + } + output.push_str(line); + } + if in_frontmatter && !found_name { + output.push_str("name: "); + output.push_str(name); + output.push_str(newline); + } + output.into_bytes() +} + +fn copy_skill_runtime_tree( + source_directory: &Path, + skill_file: &Path, + destination: &Path, + name: &str, +) -> Result<(), String> { + let files = sorted_directory_files(source_directory)?; + for (relative, path) in files { + let target = destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR)); + if path == skill_file { + let content = fs::read(&path) + .map_err(|error| format!("读取客户端 Skill 失败:{}: {error}", path.display()))?; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建运行时 Skill 目录失败:{}: {error}", parent.display()) + })?; + } + fs::write(&target, rewrite_skill_name(&content, name)) + .map_err(|error| format!("写入运行时 Skill 失败:{}: {error}", target.display()))?; + } else { + copy_file(&path, &target)?; + } + } + Ok(()) +} + +pub(crate) fn prepare_enabled_client_skill_root( + isolated_os_home: &Path, +) -> Result, String> { + let root = extensions_root()?; + let index = read_index(&root)?; + let client_skill_root = isolated_os_home.join(".agents").join("client-skills"); + let mut copied_any = false; + + for item in index + .items + .iter() + .filter(|item| item.extension_type == "skill" && item.enabled) + { + let Some(source) = index + .sources + .iter() + .find(|source| source.id == item.source_id) + else { + continue; + }; + let source_root = root.join(&source.storage_path); + let source_relative_path = Path::new(&item.source_relative_path); + let skill_file = source_root.join(source_relative_path); + if !skill_file.is_file() { + continue; + } + let source_directory = skill_file.parent().unwrap_or(&source_root); + let destination = client_skill_root.join(&item.name); + if !copied_any { + fs::create_dir_all(&client_skill_root).map_err(|error| { + format!( + "创建客户端 Skill 运行时目录失败:{}: {error}", + client_skill_root.display() + ) + })?; + copied_any = true; + } + copy_skill_runtime_tree(source_directory, &skill_file, &destination, &item.name)?; + } + + Ok(copied_any.then_some(client_skill_root)) +} + #[tauri::command] pub(crate) async fn pick_client_extension_file( app: tauri::AppHandle, @@ -824,6 +983,57 @@ mod tests { assert_eq!(parse_skill_name(&path), "skill"); } + #[test] + fn runtime_skill_copy_rewrites_only_frontmatter_name() { + let directory = tempfile::tempdir().expect("temp directory"); + let source = directory.path().join("source"); + let destination = directory.path().join("destination"); + fs::create_dir_all(source.join("references")).expect("create source tree"); + let skill_file = source.join("SKILL.md"); + fs::write( + &skill_file, + "---\nname: original-skill\ndescription: keep this\n---\n\n# original-skill\n", + ) + .expect("write source skill"); + fs::write(source.join("references/guide.md"), "reference\n").expect("write reference"); + + copy_skill_runtime_tree(&source, &skill_file, &destination, "renamed-skill") + .expect("copy runtime skill"); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).expect("read runtime skill"), + "---\nname: renamed-skill\ndescription: keep this\n---\n\n# original-skill\n" + ); + assert_eq!( + fs::read_to_string(destination.join("references/guide.md")).expect("read reference"), + "reference\n" + ); + } + + #[test] + fn client_skill_fingerprint_tracks_enabled_names_and_content() { + let mut index = ClientExtensionIndex::default(); + index.items.push(StoredExtensionItem { + id: "skill-1".to_string(), + source_id: "source-1".to_string(), + extension_type: "skill".to_string(), + name: "first-skill".to_string(), + original_name: "first-skill".to_string(), + source_relative_path: "first/SKILL.md".to_string(), + enabled: true, + fingerprint: "content-a".to_string(), + last_error: None, + mcp_config: None, + }); + let first = client_skill_set_fingerprint(&index); + index.items[0].enabled = false; + let disabled = client_skill_set_fingerprint(&index); + assert_ne!(first, disabled); + index.items[0].enabled = true; + index.items[0].name = "renamed-skill".to_string(); + let renamed = client_skill_set_fingerprint(&index); + assert_ne!(first, renamed); + } + #[test] fn fixture_directory_splits_independent_skill_and_mcp_items() { let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index bdbb7d208..b2dcf586a 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -413,6 +413,8 @@ remove_client_extension(id) ### 阶段 2:Skill 运行时闭环 +当前实施状态:已完成。DirectProject 启动准备会在隔离 HOME 中分别创建内置 AGC Skill root 和客户端 Skill root,并通过同一次 `skills/extraRoots/set` 注册;客户端 Skill root 只包含当前 `enabled=true` 的 Skill 独立副本。用户重命名时仅在该临时副本的标准 frontmatter 中更新 `name`,原始来源副本和客户端索引保持不变。已启用 Skill 集合的名称、来源相对路径和内容指纹已纳入 DirectProject app-server pool key,启停或重命名从下一次启动生效,不热更新现有连接。 + 完成: - 读取客户端已启用 Skill;