diff --git a/.gitignore b/.gitignore index a9d1e9e80..2770d44c8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json +/apps/ai-game-creator-shell/src-tauri/resources/plugins/ +/plugins/agc-cocos-editor/native/payload/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 041c69e1b..a6f5baee8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -126,11 +126,7 @@ const allowedUncalledTauriCommands = [ 'call_agc_plugin', 'read_agc_plugin_panel', 'set_agc_plugin_project_path', - 'prepare_cocos_editor_injection', - 'ping_cocos_editor', - 'status_cocos_editor', - 'execute_cocos_editor_code', - 'inject_cocos_editor', + 'set_agc_plugin_enabled', ]; const sourceExtensions = new Set([ '.json', @@ -1313,7 +1309,7 @@ const expectedBundledWindowsResources = { 'codex/win-x64/codex-package.json', 'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md', 'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json', - 'resources/cocos-editor-bridge': 'cocos-editor-bridge', + 'resources/plugins': 'plugins', }; if (tauriConfig.bundle?.resources !== undefined) { throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 7a2344a9e..cbaa6255d 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -738,6 +738,7 @@ name = "cocos-editor-bridge" version = "0.1.0" dependencies = [ "cc", + "editor-adapter-api", "serde", "serde_json", "sha2", @@ -1216,6 +1217,14 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "editor-adapter-api" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "either" version = "1.16.0" @@ -1721,6 +1730,7 @@ dependencies = [ "base64 0.22.1", "chromiumoxide", "cocos-editor-bridge", + "editor-adapter-api", "futures", "getrandom 0.3.4", "http", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 7197ad8a2..c524583aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -22,7 +22,8 @@ ts-rs = "12.0.1" typed_floats = { version = "1.0.7", features = ["serde"] } nalgebra = { version = "0.35.0", features = ["serde-serialize"] } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } -cocos-editor-bridge = { path = "../../../server-rs/crates/cocos-editor-bridge", default-features = false } +cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false } +editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" } base64 = "0.22" axum = "0.8" chromiumoxide = "0.9.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 45dbb5911..4382abeef 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -182,6 +182,7 @@ fn main() { ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); stage_bundled_codex_cli(&manifest_dir); + stage_plugin_workspace(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); @@ -223,13 +224,113 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { let source = candidates .iter() .find(|path| path.is_file()) - .unwrap_or_else(|| panic!("Cocos bridge native payload 未构建:{}", candidates.iter().map(|p| p.display().to_string()).collect::>().join(";"))); - let destination = manifest_dir.join("resources/cocos-editor-bridge/cocos-editor-bridge.dll"); - std::fs::create_dir_all(destination.parent().expect("payload resource parent")) - .expect("创建 Cocos bridge 资源目录失败"); - std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); + .unwrap_or_else(|| { + panic!( + "Cocos bridge native payload 未构建:{}", + candidates + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(";") + ) + }); + for destination in [ + // 插件工作区里的 payload 是开发态与打包态的唯一真源。 + manifest_dir + .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), + // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。 + manifest_dir + .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), + ] { + std::fs::create_dir_all(destination.parent().expect("payload resource parent")) + .expect("创建 Cocos bridge payload 目录失败"); + std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); + } println!("cargo:rerun-if-changed={}", source.display()); } #[cfg(not(windows))] fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} + +/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 +/// +/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 +/// Cargo target 目录或 node_modules。 +#[cfg(windows)] +fn stage_plugin_workspace(manifest_dir: &std::path::Path) { + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下") + .to_path_buf(); + let workspace = repo_root.join("plugins"); + let destination_root = manifest_dir.join("resources/plugins"); + std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); + let entries = match std::fs::read_dir(&workspace) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let plugin_root = entry.path(); + if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { + continue; + } + let name = entry.file_name(); + let destination = destination_root.join(&name); + copy_plugin_file( + &plugin_root.join("plugin.json"), + &destination.join("plugin.json"), + ); + for relative in [ + std::path::PathBuf::from("src"), + std::path::PathBuf::from("panels"), + std::path::PathBuf::from("native/payload"), + ] { + copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + } + println!("cargo:rerun-if-changed={}", plugin_root.display()); + } +} + +#[cfg(windows)] +fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { + let entries = match std::fs::read_dir(source) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let target = destination.join(entry.file_name()); + let path = entry.path(); + if path.is_dir() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if matches!(name.as_ref(), "target" | "node_modules" | ".git") { + continue; + } + std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); + copy_plugin_tree(&path, &target); + } else { + // 测试文件不随包分发。 + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.contains(".test.") { + continue; + } + copy_plugin_file(&path, &target); + } + } +} + +#[cfg(windows)] +fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { + if !source.is_file() { + return; + } + std::fs::create_dir_all(destination.parent().expect("插件资源父目录")) + .expect("创建插件资源目录失败"); + std::fs::copy(source, destination).expect("复制插件资源失败"); +} + +#[cfg(not(windows))] +fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index b5cd2ae74..5f94f6e0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2301,35 +2301,56 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) #[cfg(all(windows, feature = "cocos-editor-execute"))] async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { + return bridge_tool_result( + "Cocos Creator 插件已禁用,agc_cocos_execute 不可用".to_string(), + Vec::new(), + true, + ); + } let prepared = (|| { bridge_reject_unknown_fields(arguments, &["code"])?; enforce_project_permission_policy(&state.root, "cocos.editor.execute")?; - let code = arguments.get("code").and_then(Value::as_str) + let code = arguments + .get("code") + .and_then(Value::as_str) .ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?; cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string())?; Ok::<_, String>(code.to_string()) })(); let code = match prepared { Ok(code) => code, - Err(error) => return bridge_tool_result( - redact_agent_runtime_error(&state.root, &error, 480), Vec::new(), true, - ), + Err(error) => { + return bridge_tool_result( + redact_agent_runtime_error(&state.root, &error, 480), + Vec::new(), + true, + ) + } }; let mut uncertain = state.cocos_execute_uncertain.lock().await; if *uncertain { - return bridge_tool_result(json!({ - "status": "needs-reconciliation", "retryAllowed": false, - "message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令" - }).to_string(), Vec::new(), true); + return bridge_tool_result( + json!({ + "status": "needs-reconciliation", "retryAllowed": false, + "message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令" + }) + .to_string(), + Vec::new(), + true, + ); } let root = state.root.clone(); let result = tokio::task::spawn_blocking(move || { let _lock = acquire_project_write_lock(&root, "direct-cocos.execute") .map_err(cocos_editor_bridge::BridgeError::InvalidInput)?; cocos_editor_bridge::execute_cocos_editor_code_for_project( - root.to_string_lossy().as_ref(), &code, cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS, + root.to_string_lossy().as_ref(), + &code, + cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS, ) - }).await; + }) + .await; match result { Ok(Ok(response)) => { let is_error = !response.ok; @@ -2338,24 +2359,40 @@ async fn bridge_cocos_execute(state: &DirectToolBridgeState, arguments: &Value) "requestId": response.request_id, "result": response.result, "error": response.error, - }).to_string(); - bridge_tool_result(redact_agent_runtime_project_paths(&state.root, &text, 32_000), Vec::new(), is_error) + }) + .to_string(); + bridge_tool_result( + redact_agent_runtime_project_paths(&state.root, &text, 32_000), + Vec::new(), + is_error, + ) } failed => { let (is_uncertain, error) = match failed { Ok(Err(error)) => ( - matches!(&error, cocos_editor_bridge::BridgeError::ExecutionUncertain(_)), + matches!( + &error, + cocos_editor_bridge::BridgeError::ExecutionUncertain(_) + ), error.to_string(), ), - Err(_) => (true, "Cocos execute worker 退出,执行结果需要核对".to_string()), + Err(_) => ( + true, + "Cocos execute worker 退出,执行结果需要核对".to_string(), + ), Ok(Ok(_)) => unreachable!(), }; *uncertain = is_uncertain; - bridge_tool_result(json!({ - "status": if is_uncertain { "needs-reconciliation" } else { "failed" }, - "retryAllowed": !is_uncertain, - "message": redact_agent_runtime_error(&state.root, &error, 480), - }).to_string(), Vec::new(), true) + bridge_tool_result( + json!({ + "status": if is_uncertain { "needs-reconciliation" } else { "failed" }, + "retryAllowed": !is_uncertain, + "message": redact_agent_runtime_error(&state.root, &error, 480), + }) + .to_string(), + Vec::new(), + true, + ) } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 9db357363..edf69c1b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -432,16 +432,18 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { ]; let mut tools = tools; #[cfg(all(windows, feature = "cocos-editor-execute"))] - tools.push(json!({ - "name": "agc_cocos_execute", - "description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。", - "inputSchema": { - "type": "object", - "properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } }, - "required": ["code"], - "additionalProperties": false - } - })); + if crate::builtin_plugins::cocos_editor_agent_tool_available() { + tools.push(json!({ + "name": "agc_cocos_execute", + "description": "在当前项目已连接的 Cocos Creator 主进程执行 JavaScript 函数体,支持 await 和 return。宿主绑定项目和目标进程,只提交 code。结果待核对或超时后禁止自动重发;使用 Editor.Message 调用 Creator API。", + "inputSchema": { + "type": "object", + "properties": { "code": { "type": "string", "minLength": 1, "maxLength": cocos_editor_bridge::MAX_EXECUTE_CODE_BYTES } }, + "required": ["code"], + "additionalProperties": false + } + })); + } if controlled_web_search { tools.push(json!({ "name": "agc_web_search", @@ -517,7 +519,9 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> { #[cfg(all(windows, feature = "cocos-editor-execute"))] async fn call_agc_cocos_execute(arguments: &Value) -> Value { let validated = validate_tool_object_fields(arguments, &["code"]).and_then(|()| { - let code = arguments.get("code").and_then(Value::as_str) + let code = arguments + .get("code") + .and_then(Value::as_str) .ok_or_else(|| "code 必须是 JavaScript 函数体".to_string())?; cocos_editor_bridge::validate_execute_code(code).map_err(|error| error.to_string()) }); @@ -1896,7 +1900,9 @@ mod tests { "agc_browser_playtest", ] .into_iter() - .chain(cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute")) + .chain( + cfg!(all(windows, feature = "cocos-editor-execute")).then_some("agc_cocos_execute") + ) .collect::>() ); let serialized = specs.to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 25fbc0bbc..49e1a9da9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -17,7 +17,7 @@ mod canvas_asset_kind_contract_tests { } pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { - let tools = vec![ + let mut tools = vec![ GAME_CREATOR_USER_INPUT_REQUEST_TOOL, "memory.read", "memory.write", @@ -64,10 +64,12 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "agent.action_history", "agent.run_status", ]; + // 内置插件被用户禁用后,对应 Runtime 工具不再进入工具目录、Agent 上下文 + // 和工具策略快照。 + if crate::builtin_plugins::cocos_editor_agent_tool_available() { + tools.push(crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME); + } tools - .into_iter() - .chain(cfg!(feature = "cocos-editor-execute").then_some("cocos.editor.execute")) - .collect() } pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 6028ea788..41235d21e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -1,8 +1,8 @@ use super::*; mod action_history; -mod command_ops; mod cocos_editor; +mod command_ops; mod context; mod delegation; mod delivery; @@ -21,8 +21,8 @@ mod task_ops; mod ui_workflow; pub(in crate::agent) use action_history::*; -pub(in crate::agent) use command_ops::*; pub(in crate::agent) use cocos_editor::*; +pub(in crate::agent) use command_ops::*; pub(in crate::agent) use context::*; pub(in crate::agent) use delegation::*; pub(in crate::agent) use delivery::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 17dce95af..dc2528401 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -251,8 +251,17 @@ fn build_agent_runtime_native_capability_registry() -> Result Result<&'static CapabilityRegistry, String> { - static REGISTRY: OnceLock, String>> = OnceLock::new(); - REGISTRY + // 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即 + // 生效,又不需要每次调用都重建 registry。 + static ENABLED_REGISTRY: OnceLock, String>> = OnceLock::new(); + static DISABLED_REGISTRY: OnceLock, String>> = + OnceLock::new(); + let cache = if crate::builtin_plugins::cocos_editor_agent_tool_available() { + &ENABLED_REGISTRY + } else { + &DISABLED_REGISTRY + }; + cache .get_or_init(build_agent_runtime_native_capability_registry) .as_ref() .map_err(Clone::clone) diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs new file mode 100644 index 000000000..2107b78c1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -0,0 +1,321 @@ +//! 内置插件登记表与可用开关。 +//! +//! 内置插件随 `plugins/` 工作区随包分发,用户不能卸载,只能控制是否可用。 +//! 开关状态持久化在 AppData `extensions/builtin-plugins.json`,同时被两处消费: +//! 插件宿主(禁用后不能启动,状态显示为 disabled)和 Agent 工具目录(禁用后 +//! 不出现在工具列表与 Agent 上下文里)。 + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use serde::{Deserialize, Serialize}; + +/// Cocos Creator 编辑器插件的插件 id,与 `plugins/agc-cocos-editor/plugin.json` 一致。 +pub(crate) const AGC_COCOS_EDITOR_PLUGIN_ID: &str = "agc-cocos-editor"; + +/// 该插件在 Agent 侧对应的 Runtime 工具名。 +pub(crate) const AGC_COCOS_EDITOR_TOOL_NAME: &str = "cocos.editor.execute"; + +const STATE_FILE_NAME: &str = "builtin-plugins.json"; +const STATE_SCHEMA_VERSION: &str = "agc.builtin-plugins.v1"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BuiltinPlugin { + CocosEditor, +} + +impl BuiltinPlugin { + pub(crate) fn id(self) -> &'static str { + match self { + Self::CocosEditor => AGC_COCOS_EDITOR_PLUGIN_ID, + } + } + + /// 未持久化任何开关时的默认状态。 + fn default_enabled(self) -> bool { + match self { + Self::CocosEditor => true, + } + } + + /// 该插件是否向 Agent 暴露 Runtime 工具。 + fn exposes_agent_tools(self) -> bool { + match self { + Self::CocosEditor => true, + } + } +} + +pub(crate) const BUILTIN_PLUGINS: &[BuiltinPlugin] = &[BuiltinPlugin::CocosEditor]; + +pub(crate) fn builtin_plugin(id: &str) -> Option { + BUILTIN_PLUGINS + .iter() + .copied() + .find(|plugin| plugin.id() == id.trim()) +} + +pub(crate) fn is_builtin(id: &str) -> bool { + builtin_plugin(id).is_some() +} + +#[derive(Debug, Default)] +struct BuiltinPluginState { + path: Option, + enabled: BTreeMap, + /// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。 + fail_closed: bool, +} + +static STATE: OnceLock> = OnceLock::new(); + +fn state() -> &'static Mutex { + STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default())) +} + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BuiltinPluginStateFile { + #[serde(default)] + schema_version: Option, + #[serde(default)] + enabled: BTreeMap, +} + +/// 读取 AppData 里的开关状态;文件缺失按默认状态处理,坏文件失败关闭。 +pub(crate) fn initialize(config_dir: &Path) -> Result<(), String> { + let root = config_dir.join("extensions"); + let path = root.join(STATE_FILE_NAME); + if let Err(error) = fs::create_dir_all(&root) { + mark_fail_closed(Some(path)); + return Err(format!("准备内置插件目录失败:{error}")); + } + let loaded = match fs::read(&path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(file) if file.schema_version.as_deref() == Some(STATE_SCHEMA_VERSION) => file, + Ok(_) => { + mark_fail_closed(Some(path)); + return Err(format!( + "内置插件开关文件版本不受支持,需要 {STATE_SCHEMA_VERSION}" + )); + } + Err(error) => { + mark_fail_closed(Some(path)); + return Err(format!("内置插件开关文件无效:{error}")); + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + BuiltinPluginStateFile::default() + } + Err(error) => { + mark_fail_closed(Some(path)); + return Err(format!("读取内置插件开关失败:{error}")); + } + }; + let mut guard = state() + .lock() + .map_err(|_| "内置插件状态锁已损坏".to_string())?; + guard.path = Some(path); + guard.fail_closed = false; + // 只接受登记表里的 id,避免坏文件把未知对象带进运行时。 + guard.enabled = loaded + .enabled + .into_iter() + .filter(|(id, _)| is_builtin(id)) + .collect(); + Ok(()) +} + +fn mark_fail_closed(path: Option) { + if let Ok(mut guard) = state().lock() { + guard.path = path; + guard.fail_closed = true; + guard.enabled = BUILTIN_PLUGINS + .iter() + .map(|plugin| (plugin.id().to_string(), false)) + .collect(); + } +} + +pub(crate) fn is_enabled(id: &str) -> bool { + let Some(plugin) = builtin_plugin(id) else { + return false; + }; + state() + .lock() + .map(|guard| { + if guard.fail_closed { + return false; + } + guard + .enabled + .get(plugin.id()) + .copied() + .unwrap_or_else(|| plugin.default_enabled()) + }) + // 状态锁损坏时同样 fail-closed,避免异常状态重新放开内置能力。 + .unwrap_or(false) +} + +/// 内置插件的用户开关;`None` 表示该 id 不是内置插件,由来源自己决定启用状态。 +pub(crate) fn toggle_state(id: &str) -> Option { + builtin_plugin(id).map(|_| is_enabled(id)) +} + +pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result { + let Some(plugin) = builtin_plugin(id) else { + return Err(format!("{id} 不是内置插件,不能使用内置插件开关")); + }; + let mut guard = state() + .lock() + .map_err(|_| "内置插件状态锁已损坏".to_string())?; + let previous = guard.enabled.get(plugin.id()).copied(); + guard.enabled.insert(plugin.id().to_string(), enabled); + if let Err(error) = persist(&guard) { + match previous { + Some(value) => { + guard.enabled.insert(plugin.id().to_string(), value); + } + None => { + guard.enabled.remove(plugin.id()); + } + } + return Err(error); + } + Ok(enabled) +} + +fn persist(guard: &BuiltinPluginState) -> Result<(), String> { + let path = guard + .path + .clone() + .ok_or_else(|| "内置插件开关尚未初始化".to_string())?; + let file = BuiltinPluginStateFile { + schema_version: Some(STATE_SCHEMA_VERSION.to_string()), + enabled: guard.enabled.clone(), + }; + let bytes = serde_json::to_vec_pretty(&file) + .map_err(|error| format!("序列化内置插件开关失败:{error}"))?; + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, bytes).map_err(|error| format!("写入内置插件开关失败:{error}"))?; + fs::rename(&temporary, &path).map_err(|error| format!("提交内置插件开关失败:{error}"))?; + Ok(()) +} + +/// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 +pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { + plugin.exposes_agent_tools() + && cfg!(feature = "cocos-editor-execute") + && is_enabled(plugin.id()) +} + +pub(crate) fn cocos_editor_agent_tool_available() -> bool { + agent_tool_available(BuiltinPlugin::CocosEditor) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + + #[test] + fn builtin_plugins_default_to_enabled_and_reject_unknown_ids() { + let _guard = test_lock(); + let directory = tempdir().expect("temp config"); + initialize(directory.path()).expect("initialize"); + assert!(is_builtin(AGC_COCOS_EDITOR_PLUGIN_ID)); + assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(true)); + assert_eq!(toggle_state("imported-plugin"), None); + assert!(!is_enabled("imported-plugin")); + assert!(set_enabled("imported-plugin", false).is_err()); + } + + #[test] + fn toggle_state_round_trips_through_appdata_file() { + let _guard = test_lock(); + let directory = tempdir().expect("temp config"); + initialize(directory.path()).expect("initialize"); + assert_eq!( + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"), + false + ); + let path = directory.path().join("extensions").join(STATE_FILE_NAME); + let written = fs::read_to_string(&path).expect("state file"); + assert!(written.contains(STATE_SCHEMA_VERSION)); + assert!(written.contains(AGC_COCOS_EDITOR_PLUGIN_ID)); + + // 重新初始化模拟下次启动读取持久化结果。 + initialize(directory.path()).expect("re-initialize"); + assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); + assert!(is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + } + + #[test] + fn corrupt_state_file_fails_closed() { + let _guard = test_lock(); + let directory = tempdir().expect("temp config"); + let root = directory.path().join("extensions"); + fs::create_dir_all(&root).expect("extensions dir"); + fs::write(root.join(STATE_FILE_NAME), "{ not json").expect("write corrupt state"); + assert!(initialize(directory.path()).is_err()); + assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + assert_eq!(toggle_state(AGC_COCOS_EDITOR_PLUGIN_ID), Some(false)); + } + + #[test] + fn unsupported_schema_fails_closed() { + let _guard = test_lock(); + let directory = tempdir().expect("temp config"); + let root = directory.path().join("extensions"); + fs::create_dir_all(&root).expect("extensions dir"); + fs::write( + root.join(STATE_FILE_NAME), + serde_json::json!({"schemaVersion": "agc.builtin-plugins.v0", "enabled": {}}) + .to_string(), + ) + .expect("write unsupported state"); + assert!(initialize(directory.path()).is_err()); + assert!(!is_enabled(AGC_COCOS_EDITOR_PLUGIN_ID)); + } + + #[test] + fn agent_tool_visibility_follows_the_toggle() { + let _guard = test_lock(); + let directory = tempdir().expect("temp config"); + initialize(directory.path()).expect("initialize"); + let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute"); + + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); + assert_eq!( + cocos_editor_agent_tool_available(), + tool_visible_when_enabled + ); + assert_eq!( + crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME), + tool_visible_when_enabled + ); + + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).expect("disable"); + assert!(!cocos_editor_agent_tool_available()); + assert!( + !crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME) + ); + + set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("re-enable"); + assert_eq!( + crate::agent::agent_runtime_executable_tools().contains(&AGC_COCOS_EDITOR_TOOL_NAME), + tool_visible_when_enabled + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs deleted file mode 100644 index 48cca6ab8..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/cocos_editor.rs +++ /dev/null @@ -1,172 +0,0 @@ -#[cfg(feature = "cocos-editor-injection")] -use cocos_editor_bridge::CocosEditorInjectionRequest; -use cocos_editor_bridge::{ - CocosEditorCommandResponse, CocosEditorInjectionResult, CocosEditorProcess, -}; -use serde::Deserialize; -#[cfg(feature = "cocos-editor-injection")] -use tauri::Manager; -#[cfg(feature = "cocos-editor-injection")] -const BUNDLED_COCOS_BRIDGE_PAYLOAD: &str = "cocos-editor-bridge/cocos-editor-bridge.dll"; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CocosEditorTargetRequest { - process_id: u32, - project_path: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CocosEditorCommandInput { - process_id: u32, - project_path: String, - #[serde(default = "default_command_timeout_ms")] - timeout_ms: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CocosEditorInjectionInput { - process_id: u32, - project_path: String, - #[serde(default = "default_injection_timeout_ms")] - timeout_ms: u32, -} - -fn default_command_timeout_ms() -> u32 { - cocos_editor_bridge::DEFAULT_COMMAND_TIMEOUT_MS -} - -fn default_injection_timeout_ms() -> u32 { - cocos_editor_bridge::DEFAULT_INJECTION_TIMEOUT_MS -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CocosEditorExecuteInput { - process_id: u32, - project_path: String, - code: String, - #[serde(default = "default_command_timeout_ms")] - timeout_ms: u32, -} - -#[tauri::command] -pub(crate) fn prepare_cocos_editor_injection( - request: CocosEditorTargetRequest, -) -> Result { - #[cfg(feature = "cocos-editor")] - { - return cocos_editor_bridge::validate_injection_target( - request.process_id, - &request.project_path, - ) - .map_err(|error| error.to_string()); - } - #[cfg(not(feature = "cocos-editor"))] - { - let _ = (request.process_id, request.project_path); - Err("Cocos Editor bridge feature 未启用".to_string()) - } -} - -#[tauri::command] -pub(crate) fn ping_cocos_editor( - request: CocosEditorCommandInput, -) -> Result { - #[cfg(feature = "cocos-editor-execute")] - { - return cocos_editor_bridge::ping_cocos_editor( - request.process_id, - &request.project_path, - request.timeout_ms, - ) - .map_err(|error| error.to_string()); - } - #[cfg(not(feature = "cocos-editor-execute"))] - { - let _ = (request.process_id, request.project_path, request.timeout_ms); - Err("Cocos Editor execute feature 未启用".to_string()) - } -} - -#[tauri::command] -pub(crate) fn status_cocos_editor( - request: CocosEditorCommandInput, -) -> Result { - #[cfg(feature = "cocos-editor-execute")] - { - return cocos_editor_bridge::status_cocos_editor( - request.process_id, - &request.project_path, - request.timeout_ms, - ) - .map_err(|error| error.to_string()); - } - #[cfg(not(feature = "cocos-editor-execute"))] - { - let _ = (request.process_id, request.project_path, request.timeout_ms); - Err("Cocos Editor execute feature 未启用".to_string()) - } -} - -#[tauri::command] -pub(crate) fn execute_cocos_editor_code( - request: CocosEditorExecuteInput, -) -> Result { - #[cfg(feature = "cocos-editor-execute")] - { - return cocos_editor_bridge::execute_cocos_editor_code( - request.process_id, - &request.project_path, - &request.code, - request.timeout_ms, - ) - .map_err(|error| error.to_string()); - } - #[cfg(not(feature = "cocos-editor-execute"))] - { - let _ = ( - request.process_id, - request.project_path, - request.code, - request.timeout_ms, - ); - Err("Cocos Editor execute feature 未启用".to_string()) - } -} - -#[tauri::command] -pub(crate) fn inject_cocos_editor( - app: tauri::AppHandle, - input: CocosEditorInjectionInput, -) -> Result { - #[cfg(feature = "cocos-editor-injection")] - { - let payload = app - .path() - .resource_dir() - .map_err(|error| format!("解析 AGC 资源目录失败:{error}"))? - .join(BUNDLED_COCOS_BRIDGE_PAYLOAD); - if !payload.is_file() { - return Err(format!( - "AGC 未随包提供 Cocos bridge payload:{}", - BUNDLED_COCOS_BRIDGE_PAYLOAD - )); - } - let request = CocosEditorInjectionRequest { - process_id: input.process_id, - project_path: input.project_path, - bridge_dll_path: payload.to_string_lossy().into_owned(), - timeout_ms: input.timeout_ms, - }; - return cocos_editor_bridge::inject_bridge_dll(&request).map_err(|error| error.to_string()); - } - #[cfg(not(feature = "cocos-editor-injection"))] - { - let _ = app; - let _ = (input.process_id, input.project_path, input.timeout_ms); - Err("Cocos Editor injection feature 未启用;当前构建只支持目标预检".to_string()) - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs index 108099f8f..c3c384ada 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs @@ -1,36 +1,8 @@ //! Editor adapters used by the generic AGC plugin host. //! //! The host owns plugin lifecycle, RPC, permissions and auditing. Adapters -//! only know how to find and talk to a particular editor. +//! only know how to find and talk to a particular editor, and ship inside the +//! plugin package they belong to under the `plugins/` workspace. This module +//! only re-exports the shared contract so the host stays editor-agnostic. -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct EditorConnectionInfo { - pub(crate) adapter: String, - pub(crate) connected: bool, - pub(crate) pid: Option, - pub(crate) project_path: Option, - pub(crate) version: Option, -} - -/// Narrow seam between the generic plugin runtime and a target editor. -pub(crate) trait EditorAdapter: Send + Sync { - fn id(&self) -> &'static str; - fn detect(&self, project_path: &Path) -> Result; - fn connect( - &mut self, - pid: u32, - project_path: &Path, - version: &str, - ) -> Result; - fn disconnect(&mut self); - fn translate_rpc(&self, method: &str, params: Value) -> Result; - fn rpc(&self, _method: &str, _params: Value) -> Result { - Err("编辑器原生连接尚未建立".to_string()) - } -} +pub(crate) use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs new file mode 100644 index 000000000..ba2949e8b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -0,0 +1,54 @@ +//! 已链接的编辑器适配器目录。 +//! +//! 编辑器专属实现随 `plugins/` 工作区里的插件包分发;其中 native 适配器模块 +//! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` +//! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 + +#[cfg(feature = "cocos-editor")] +use std::path::PathBuf; + +#[cfg(feature = "cocos-editor")] +use tauri::Manager; + +use crate::plugin_host::PluginHost; + +/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 +#[allow(dead_code)] +pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; + +pub(crate) fn register_linked_editor_adapters( + app: &tauri::AppHandle, + host: &PluginHost, +) -> Result<(), String> { + #[cfg(feature = "cocos-editor")] + { + let adapter = + cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); + host.register_editor_adapter(Box::new(adapter))?; + } + #[cfg(not(feature = "cocos-editor"))] + { + let _ = (app, host); + } + Ok(()) +} + +#[cfg(feature = "cocos-editor")] +fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE)); + } + // 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。 + #[cfg(debug_assertions)] + { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + candidates.push( + manifest_dir + .join("../../../plugins/agc-cocos-editor/native/payload") + .join("cocos-editor-bridge.dll"), + ); + } + candidates +} 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 bcb776d7d..699a042af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -244,6 +244,7 @@ mod agent; mod agent_native_tools; mod assets; mod browser; +mod builtin_plugins; mod cli; mod client_extensions; mod collaboration; @@ -251,7 +252,6 @@ mod command_exec; mod command_output; mod command_sandbox; mod command_sandbox_trampoline; -mod cocos_editor; mod commands; mod config; mod context_compaction; @@ -260,6 +260,7 @@ mod context_menu; mod debug; mod delegation; mod editor_adapter; +mod editor_adapters; pub mod error_report; mod git_inspect; mod goal; @@ -294,7 +295,6 @@ use collaboration::*; use command_exec::*; use command_output::*; use command_sandbox::*; -use cocos_editor::*; use commands::*; use config::*; use context_compaction::*; @@ -308,8 +308,8 @@ use patchset::*; use platform_session::*; use plugin_host::{ call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, - refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_project_path, start_agc_plugin, - stop_agc_plugin, PluginHost, + refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, + start_agc_plugin, stop_agc_plugin, PluginHost, }; use preview::*; use process_session::*; @@ -2504,9 +2504,23 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + if let Err(error) = builtin_plugins::initialize(&config_dir) { + app_log!("startup.builtin-plugins.initialize.failed: {error}"); + } if let Err(error) = app.state::().initialize(&config_dir) { app_log!("startup.plugin-host.initialize.failed: {error}"); } + if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { + if let Err(error) = app.state::().set_plugin_workspace(workspace) { + app_log!("startup.plugin-host.workspace.failed: {error}"); + } + } + if let Err(error) = editor_adapters::register_linked_editor_adapters( + app.handle(), + app.state::().inner(), + ) { + app_log!("startup.plugin-host.adapter.failed: {error}"); + } load_platform_session_fixture_from_env(&config_dir).map_err(|error| { std::io::Error::new( std::io::ErrorKind::PermissionDenied, @@ -2589,6 +2603,7 @@ fn main() { start_agc_plugin, stop_agc_plugin, reload_agc_plugin, + set_agc_plugin_enabled, call_agc_plugin, read_agc_plugin_panel, set_agc_plugin_project_path, @@ -2731,11 +2746,6 @@ fn main() { report_client_error, get_pending_error_reports, ack_error_reports, - prepare_cocos_editor_injection, - ping_cocos_editor, - status_cocos_editor, - execute_cocos_editor_code, - inject_cocos_editor, ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index b49833ae2..451a61ddb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -121,6 +121,8 @@ pub(crate) struct PluginSummary { pub(crate) version: String, pub(crate) api_version: String, pub(crate) enabled: bool, + /// 内置插件随包分发,不能卸载,只能通过可用开关启停。 + pub(crate) builtin: bool, pub(crate) has_runtime: bool, pub(crate) status: String, pub(crate) adapter: Option, @@ -140,6 +142,7 @@ pub(crate) struct AgcExtensionSummary { pub(crate) id: String, pub(crate) name: String, pub(crate) enabled: bool, + pub(crate) builtin: bool, pub(crate) status: String, pub(crate) plugin: Option, pub(crate) client_extension: Option, @@ -206,6 +209,7 @@ struct PluginRecord { #[derive(Default)] struct PluginHostState { root: Option, + workspace: Option, plugins: BTreeMap, active_project: ProjectContext, editors: EditorRegistry, @@ -461,6 +465,84 @@ fn plugin_root(config_dir: &Path) -> Result { Ok(root) } +/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, +/// 开发构建再回退仓库里的 `plugins/` 工作区。 +pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { + if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { + let workspace = PathBuf::from(workspace); + if workspace.is_dir() { + return Some(workspace); + } + } + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir.join("plugins"); + if bundled.is_dir() { + return Some(bundled); + } + } + #[cfg(debug_assertions)] + { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let workspace = repo_root.join("plugins"); + if workspace.is_dir() { + return Some(workspace); + } + } + None +} + +/// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。 +#[derive(Clone, Debug)] +struct ScannedPluginSource { + id: String, + name: String, + original_name: String, + /// `Some` 表示启用状态由来源索引决定;`None` 表示沿用插件 manifest 声明。 + enabled: Option, + root: PathBuf, +} + +impl ScannedPluginSource { + fn from_imported(source: crate::client_extensions::ClientPluginSource) -> Self { + Self { + id: source.item.id, + name: source.item.name, + original_name: source.item.original_name, + enabled: Some(source.item.enabled), + root: source.root, + } + } +} + +/// 扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的子目录是一个插件包。 +/// +/// 工作区插件随包分发:内置插件按用户可用开关决定启用状态,其它工作区插件 +/// 沿用 manifest 声明。 +fn workspace_plugin_sources(root: &Path) -> Result, String> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) => return Err(format!("读取插件工作区失败:{error}")), + }; + let mut sources = Vec::new(); + for entry in entries.flatten() { + let plugin_root = entry.path(); + if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { + continue; + } + let id = entry.file_name().to_string_lossy().into_owned(); + let enabled = crate::builtin_plugins::toggle_state(&id); + sources.push(ScannedPluginSource { + id: id.clone(), + name: id.clone(), + original_name: id, + enabled, + root: plugin_root, + }); + } + sources.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(sources) +} + fn audit_path(root: &Path) -> PathBuf { root.join(AUDIT_FILE_NAME) } @@ -691,19 +773,68 @@ impl PluginHost { .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; state.root = Some(root.clone()); + if state.workspace.is_none() { + if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { + let workspace = PathBuf::from(workspace); + if workspace.is_dir() { + state.workspace = Some(workspace); + } + } + } + self.scan_locked(&mut state, &root) + } + + /// 注册 `plugins/` 工作区目录,让随包插件无需 AppData 导入即可被发现。 + pub(crate) fn set_plugin_workspace(&self, workspace: PathBuf) -> Result<(), String> { + if !workspace.is_dir() { + return Err("插件工作区必须是目录".to_string()); + } + let mut state = self + .state + .lock() + .map_err(|_| "插件宿主锁已损坏".to_string())?; + state.workspace = Some(workspace); + let root = state + .root + .clone() + .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root) } fn scan_locked(&self, state: &mut PluginHostState, root: &Path) -> Result<(), String> { - let sources = crate::client_extensions::client_plugin_sources_at(root)?; + let workspace_sources = match state.workspace.clone() { + Some(workspace) => workspace_plugin_sources(&workspace)?, + None => Vec::new(), + }; + // 内置插件优先:随包插件不能被 AppData 同名导入覆盖,也不能被卸载。 + let mut sources = workspace_sources + .iter() + .filter(|source| crate::builtin_plugins::is_builtin(&source.id)) + .cloned() + .collect::>(); + for source in crate::client_extensions::client_plugin_sources_at(root)? + .into_iter() + .map(ScannedPluginSource::from_imported) + { + if !sources.iter().any(|existing| existing.id == source.id) { + sources.push(source); + } + } + for source in workspace_sources { + if !sources.iter().any(|existing| existing.id == source.id) { + sources.push(source); + } + } let mut discovered = BTreeMap::new(); for source in sources { - let id = source.item.id; + let id = source.id.clone(); match read_plugin_manifest(&source.root) { Ok(mut manifest) => { - manifest.enabled = source.item.enabled; - if source.item.name != source.item.original_name { - manifest.name = source.item.name; + if let Some(enabled) = source.enabled { + manifest.enabled = enabled; + } + if source.name != source.original_name { + manifest.name = source.name.clone(); } let existing = state .plugins @@ -758,7 +889,7 @@ impl PluginHost { id: id.clone(), manifest: PluginManifest { id, - name: source.item.name, + name: source.name, version: "0".to_string(), api_version: PLUGIN_API_VERSION.to_string(), entry: None, @@ -824,6 +955,7 @@ impl PluginHost { id: plugin.id.clone(), name: plugin.name.clone(), enabled: plugin.enabled, + builtin: plugin.builtin, status: plugin.status.clone(), plugin: Some(plugin), client_extension: None, @@ -848,6 +980,7 @@ impl PluginHost { id: extension.id.clone(), name: extension.name.clone(), enabled: extension.enabled, + builtin: false, status: extension.status.clone(), plugin: None, client_extension: Some(extension), @@ -933,6 +1066,25 @@ impl PluginHost { self.start(id) } + /// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。 + /// + /// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具 + /// 也不再出现在工具列表与 Agent 上下文里。 + pub(crate) fn set_enabled( + &self, + id: &str, + enabled: bool, + ) -> Result, String> { + if !crate::builtin_plugins::is_builtin(id) { + return Err("只有内置插件可以使用可用开关;导入扩展请使用扩展启用状态".to_string()); + } + if !enabled { + let _ = self.stop(id); + } + crate::builtin_plugins::set_enabled(id, enabled)?; + self.refresh() + } + pub(crate) fn read_panel( &self, id: &str, @@ -1273,7 +1425,12 @@ impl PluginHost { id.clone(), event_type.to_string(), )?; - Ok(json!({"subscriptionId": id})) + let project_path = active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .clone() + .map(|path| path.to_string_lossy().into_owned()); + Ok(json!({"subscriptionId": id, "projectPath": project_path})) } "host.events.unsubscribe" => { let id = params @@ -1353,6 +1510,7 @@ impl PluginHost { version: record.manifest.version.clone(), api_version: record.manifest.api_version.clone(), enabled: record.manifest.enabled, + builtin: crate::builtin_plugins::is_builtin(&record.id), has_runtime: record.manifest.entry.is_some(), status: record.status.clone(), adapter: record.manifest.adapter.clone(), @@ -1395,7 +1553,19 @@ impl PluginHost { if subscribed { let _ = write_rpc_shared( &running.stdin, - &json!({"jsonrpc":"2.0", "method":"host.event", "params":{"type":"project.changed"}}), + &json!({ + "jsonrpc":"2.0", + "method":"host.event", + "params":{ + "type":"project.changed", + "payload":{"projectPath": state + .active_project + .lock() + .map_err(|_| "项目上下文锁已损坏".to_string())? + .as_ref() + .map(|path| path.to_string_lossy().into_owned())}, + }, + }), ); } } @@ -1542,6 +1712,15 @@ pub(crate) fn reload_agc_plugin( host.reload(id.trim()) } +#[tauri::command] +pub(crate) fn set_agc_plugin_enabled( + id: String, + enabled: bool, + host: State<'_, PluginHost>, +) -> Result, String> { + host.set_enabled(id.trim(), enabled) +} + #[tauri::command] pub(crate) async fn call_agc_plugin( id: String, @@ -1651,6 +1830,57 @@ mod tests { assert_eq!(list[0].id, id); } + fn write_workspace_plugin(workspace: &Path, name: &str, enabled: bool) { + let plugin = workspace.join(name); + fs::create_dir_all(&plugin).expect("plugin directory"); + fs::write( + plugin.join("plugin.json"), + json!({ + "$schema": AGENT_PLUGINS_SCHEMA, + "name": name, + "version": "1.0.0", + "extensions": { + "world.genarrative.agc": { + "entry": "index.js", + "permissions": ["ui.register"], + "enabled": enabled + } + } + }) + .to_string(), + ) + .expect("manifest"); + fs::write(plugin.join("index.js"), "process.stdin.resume();").expect("entry"); + } + + #[test] + fn scans_plugins_workspace_and_honors_manifest_enabled_flag() { + let directory = tempdir().expect("temp config"); + let workspace = directory.path().join("workspace"); + write_workspace_plugin(&workspace, "sample-plugin", true); + write_workspace_plugin(&workspace, "disabled-plugin", false); + let host = PluginHost::default(); + host.initialize(directory.path()).expect("initialize"); + assert!(host.list().expect("list before workspace").is_empty()); + host.set_plugin_workspace(workspace.clone()) + .expect("set workspace"); + let list = host.list().expect("list after workspace"); + assert_eq!(list.len(), 2); + let enabled = list + .iter() + .find(|plugin| plugin.id == "sample-plugin") + .expect("enabled plugin"); + assert!(enabled.enabled); + assert_eq!(enabled.status, "stopped"); + assert_eq!(enabled.adapter, None); + let disabled = list + .iter() + .find(|plugin| plugin.id == "disabled-plugin") + .expect("disabled plugin"); + assert!(!disabled.enabled); + assert_eq!(disabled.status, "disabled"); + } + #[test] fn denies_ungranted_host_registration() { let directory = tempdir().expect("temp config"); @@ -1742,4 +1972,126 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let mut reader = std::io::Cursor::new(vec![b'a'; MAX_RPC_BYTES + 1]); assert!(read_bounded_rpc_line(&mut reader).is_err()); } + + struct StubCocosAdapter; + + impl EditorAdapter for StubCocosAdapter { + fn id(&self) -> &'static str { + "cocos-editor" + } + + fn detect(&self, _project_path: &Path) -> Result { + Err("stub adapter 不探测进程".to_string()) + } + + fn connect( + &mut self, + _pid: u32, + _project_path: &Path, + _version: &str, + ) -> Result { + Err("stub adapter 不建立连接".to_string()) + } + + fn disconnect(&mut self) {} + + fn translate_rpc(&self, _method: &str, params: Value) -> Result { + Ok(params) + } + + fn rpc(&self, method: &str, params: Value) -> Result { + // 与 native 适配器的 CocosEditorCommandResponse 同形,供插件入口判断 status。 + Ok(json!({"ok": true, "method": method, "params": params})) + } + } + + #[test] + fn builtin_plugin_toggle_controls_availability() { + let directory = tempdir().expect("temp config"); + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); + let host = PluginHost::default(); + crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); + host.initialize(directory.path()).expect("initialize"); + host.set_plugin_workspace(workspace) + .expect("set plugins workspace"); + + let summary = |list: Vec| { + list.into_iter() + .find(|plugin| plugin.id == "agc-cocos-editor") + .expect("built-in plugin") + }; + let enabled = summary(host.list().expect("list")); + assert!(enabled.builtin); + assert!(enabled.enabled); + + let disabled = summary( + host.set_enabled("agc-cocos-editor", false) + .expect("disable built-in plugin"), + ); + assert!(!disabled.enabled); + assert_eq!(disabled.status, "disabled"); + assert!(host.start("agc-cocos-editor").is_err()); + assert!(host + .set_enabled("imported-extension", false) + .expect_err("imported extensions use the extension index") + .contains("只有内置插件")); + + let re_enabled = summary( + host.set_enabled("agc-cocos-editor", true) + .expect("enable built-in plugin"), + ); + assert!(re_enabled.enabled); + assert_eq!(re_enabled.status, "stopped"); + } + + #[test] + fn workspace_cocos_plugin_round_trips_editor_rpc() { + let directory = tempdir().expect("temp config"); + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); + let host = PluginHost::default(); + host.initialize(directory.path()).expect("initialize"); + host.set_plugin_workspace(workspace) + .expect("set plugins workspace"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); + let project = fs::canonicalize(directory.path()) + .expect("canonical project") + .to_string_lossy() + .into_owned(); + host.set_active_project(Some(project.clone())) + .expect("set active project"); + host.start("agc-cocos-editor").expect("start plugin"); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + loop { + let registered = host.list().expect("list").into_iter().any(|plugin| { + plugin.id == "agc-cocos-editor" + && plugin.commands.len() == 1 + && plugin.capabilities.len() == 1 + && plugin.panels.len() == 1 + }); + if registered { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Cocos 插件未在期限内完成注册" + ); + thread::sleep(Duration::from_millis(25)); + } + let response = host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code": "return 1 + 1;"}), + ) + .expect("cocos execute rpc"); + assert_eq!(response["status"], "completed"); + assert_eq!(response["response"]["method"], "editor.execute"); + assert_eq!(response["response"]["params"]["projectPath"], project); + assert_eq!(response["response"]["params"]["code"], "return 1 + 1;"); + assert_eq!( + host.stop("agc-cocos-editor").expect("stop plugin").status, + "stopped" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json index f1b354361..30c71cc2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json @@ -12,7 +12,7 @@ "resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json", "resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md", "resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json", - "resources/cocos-editor-bridge": "cocos-editor-bridge" + "resources/plugins": "plugins" } } } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 24c2686c0..bd67ab570 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -105,6 +105,8 @@ export type AgcPluginSummary = { version: string; apiVersion: string; enabled: boolean; + /** 内置插件随包分发、不能卸载,只能通过可用开关控制。 */ + builtin: boolean; hasRuntime: boolean; status: AgcPluginStatus; adapter: string | null; @@ -120,6 +122,7 @@ export type AgcExtensionSummary = { id: string; name: string; enabled: boolean; + builtin: boolean; status: string; plugin: AgcPluginSummary | null; clientExtension: ClientExtensionItem | null; diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 529803b4d..4a733b7d2 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -37,6 +37,7 @@ import { checkForAppUpdate } from '../../services/appUpdate'; import { listAgcExtensions, reloadAgcPlugin, + setAgcPluginEnabled, setAgcPluginProjectPath, startAgcPlugin, stopAgcPlugin, @@ -346,6 +347,27 @@ export function RuntimeConfigDialog({ } } + /** 内置插件没有卸载入口,只用可用开关控制是否对客户端和 Agent 生效。 */ + async function setBuiltinPluginEnabled( + plugin: AgcPluginSummary, + enabled: boolean, + ) { + if (agcPluginsBusy) return; + setAgcPluginsBusy(true); + try { + const plugins = await setAgcPluginEnabled(plugin.id, enabled); + setAgcPlugins(plugins); + await readClientExtensions(); + setAgcPluginsStatus(enabled ? '内置插件已启用' : '内置插件已禁用'); + } catch (error) { + setAgcPluginsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setAgcPluginsBusy(false); + } + } + async function readClientExtensions() { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -940,93 +962,40 @@ export function RuntimeConfigDialog({ {clientExtensionsStatus}

) : null} - {clientExtensionsLoadState === 'loading' ? ( -
- 正在加载扩展 - 正在读取已导入的 Skill 和 MCP。 -
- ) : clientExtensionsLoadState === 'error' ? ( -
- 扩展列表加载失败 - - {clientExtensionsStatus || '暂时无法读取扩展列表。'} - -
- ) : clientExtensions.length > 0 ? ( + {agcPlugins.some((plugin) => plugin.builtin) ? (
- {clientExtensions.map((item) => { - const editing = editingExtensionId === item.id; - const plugin = agcPlugins.find( - (plugin) => plugin.id === item.id, - ); - const typeLabel = - item.extensionType === 'plugin' - ? 'Plugin' - : item.extensionType === 'skill' - ? 'Skill' - : item.extensionType === 'mcp' - ? 'MCP' - : '未识别'; - const statusLabel = - item.status === 'enabled' - ? '已启用' - : item.status === 'disabled' - ? '已禁用' - : item.status === 'startup-failed' - ? '启动失败' - : '当前不可用'; - return ( + {agcPlugins + .filter((plugin) => plugin.builtin) + .map((plugin) => (
- {editing ? ( - - setEditingExtensionName( - event.currentTarget.value, - ) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - void saveClientExtensionName(item); - } else if (event.key === 'Escape') { - event.stopPropagation(); - cancelRenameClientExtension(); - } - }} - /> - ) : ( - {item.name} - )} - - {typeLabel} · 来自 {item.sourceName} - - {item.lastError ? ( - - {item.lastError} + {plugin.name} + 内置 Plugin · 不可卸载 + {plugin.lastError ? ( + + {plugin.lastError} ) : null}
- {plugin?.status === 'running' - ? '运行中' - : statusLabel} + {!plugin.enabled + ? '已禁用' + : plugin.status === 'running' + ? '运行中' + : plugin.status === 'invalid' + ? '当前不可用' + : '已启用'} - {plugin?.enabled && plugin.hasRuntime ? ( + {plugin.enabled && plugin.hasRuntime ? ( <> ) : null} - {plugin?.status === 'running' + {plugin.status === 'running' ? plugin.panels.map((panel) => ( - - - ) : ( - - )} - {item.extensionType === 'unknown' ? null : ( - - )}
- ); - })} + ))} +
+ ) : null} + {clientExtensionsLoadState === 'loading' ? ( +
+ 正在加载扩展 + 正在读取已导入的 Skill 和 MCP。 +
+ ) : clientExtensionsLoadState === 'error' ? ( +
+ 扩展列表加载失败 + + {clientExtensionsStatus || '暂时无法读取扩展列表。'} + +
+ ) : clientExtensions.length > 0 ? ( +
+ {clientExtensions + .filter( + (item) => + !( + item.extensionType === 'plugin' && + agcPlugins.some( + (plugin) => + plugin.builtin && plugin.id === item.id, + ) + ), + ) + .map((item) => { + const editing = editingExtensionId === item.id; + const plugin = agcPlugins.find( + (plugin) => plugin.id === item.id, + ); + const typeLabel = + item.extensionType === 'plugin' + ? 'Plugin' + : item.extensionType === 'skill' + ? 'Skill' + : item.extensionType === 'mcp' + ? 'MCP' + : '未识别'; + const statusLabel = + item.status === 'enabled' + ? '已启用' + : item.status === 'disabled' + ? '已禁用' + : item.status === 'startup-failed' + ? '启动失败' + : '当前不可用'; + return ( +
+
+ {editing ? ( + + setEditingExtensionName( + event.currentTarget.value, + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + void saveClientExtensionName(item); + } else if (event.key === 'Escape') { + event.stopPropagation(); + cancelRenameClientExtension(); + } + }} + /> + ) : ( + {item.name} + )} + + {typeLabel} · 来自 {item.sourceName} + + {item.lastError ? ( + + {item.lastError} + + ) : null} +
+
+ + {plugin?.status === 'running' + ? '运行中' + : statusLabel} + + {plugin?.enabled && plugin.hasRuntime ? ( + <> + + + + ) : null} + {plugin?.status === 'running' + ? plugin.panels.map((panel) => ( + + )) + : null} + {editing ? ( + <> + + + + ) : ( + + )} + {item.extensionType === 'unknown' ? null : ( + + )} + +
+
+ ); + })}
) : (
diff --git a/apps/ai-game-creator-shell/src/services/pluginHost.ts b/apps/ai-game-creator-shell/src/services/pluginHost.ts index 22a5f61ca..3ca0f9bf7 100644 --- a/apps/ai-game-creator-shell/src/services/pluginHost.ts +++ b/apps/ai-game-creator-shell/src/services/pluginHost.ts @@ -45,6 +45,14 @@ export async function reloadAgcPlugin(id: string) { }) as Promise; } +/** 内置插件的可用开关;禁用后不能启动,对应 Agent 工具也不再出现。 */ +export async function setAgcPluginEnabled(id: string, enabled: boolean) { + return invokeOrThrow()('set_agc_plugin_enabled', { + id, + enabled, + }) as Promise; +} + export async function callAgcPlugin( id: string, method: string, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e2073640d..f52faf5b0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8211,3 +8211,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 注入仅加载随 AGC 资源目录提供的 DLL,结果先标记 `injected-unverified`,必须由 payload 完成握手后才可开放有限 Cocos 操作;Runtime 的 execute 代码有界并受确认策略保护,不开放未受限 eval 或项目扩展自动写入。 - Runtime 第一阶段只广告 `cocos.editor.execute`,代码长度有界、默认走确认策略,项目根和目标 PID 不交给模型;`ping/status` 先作为宿主命令保留,不扩大全局 Agent 工具面。 - DirectProject 的 `agc_tools` 对应入口是 `agc_cocos_execute`,同样只接收 code,并沿用当前项目权限。2026-09-10 已通过临时真实 Creator 3.8.8 验证 Node 的 Windows 调试 handler 激活 Inspector、注入 bootstrap、pipe execute 及关闭 Inspector 后继续执行;此路线尚未替换当前 native DLL 源码。现有 `RequestInterrupt` 回调不能调用 JavaScript,不能把 DLL 加载和窗口线程钩子当作可用握手。执行发送后的未知结果禁止自动重放,Direct bridge 会阻断后续 execute。详细步骤、版本/fuse 和端口边界见 Cocos bridge 技术方案。 + +## 2026-09-10 Cocos 直连模块改为插件包与独立插件工作区 + +- 决策:Cocos 直连模块从 AGC 源码树移入插件包 `plugins/agc-cocos-editor`:`plugin.json` 使用 Agent Plugins 清单加 `extensions.world.genarrative.agc`,`src/entry.mjs` 作为 Runtime 入口注册 `cocos.editor.execute` 命令、`cocos.editor.connection` 能力和 `cocos-editor` 面板,native 模块 `native/cocos-editor-bridge` 实现通用 `EditorAdapter`。 +- 决策:新增 `plugins/` 工作区(npm workspace 成员)作为插件唯一存放位置;宿主解析顺序为 `AGC_PLUGIN_WORKSPACE`、随包 `/plugins`、开发构建的仓库 `plugins/`。工作区插件按自身 manifest 声明启用状态,AppData 同名导入插件优先。 +- 决策:通用适配器契约抽到 `server-rs/crates/editor-adapter-api`,宿主 `editor_adapter` 只做 re-export,`editor_adapters.rs` 负责编译期链接注册;AGC 删除 Cocos 专属 Tauri 命令和 `src/cocos_editor.rs`,编辑器操作统一走 `host.rpc` 到 `EditorAdapter::rpc`。 +- 边界:native 适配器当前仍由宿主编译期链接(Cargo path 依赖),动态加载插件 native 模块不在本次范围;Runtime 的 `cocos.editor.execute` 工具与 DirectProject 的 `agc_cocos_execute` 继续使用同一 native 实现,共享项目锁与“结果不确定禁止重放”语义。 +- 验证:插件包 `node --test` 与 native crate 单元测试、宿主工作区扫描与 manifest 启用状态测试、AGC typecheck、`check:npm-workspaces`、编码检查分别执行;真实 Creator 注入验收仍按 Cocos 桥接方案单独执行。 + +## 2026-09-10 内置插件不可卸载与可用开关 + +- 决策:`plugins/` 工作区里的插件按内置插件处理,随客户端分发、不能卸载或删除;同名 AppData 导入插件不覆盖内置定义。内置插件在 `PluginSummary` / `AgcExtensionSummary` 里带 `builtin`,前端只显示可用开关。 +- 决策:唯一开关入口为 `set_agc_plugin_enabled`,只接受登记过的内置插件 id,状态持久化到 AppData `extensions/builtin-plugins.json`(`schemaVersion = agc.builtin-plugins.v1`);文件缺失按 manifest `enabled` 处理,坏文件失败关闭。 +- 决策:禁用时先停止运行中的插件进程并让 `start_agc_plugin` 失败;同时把对应 Runtime 工具从 `agent_runtime_executable_tools()` 移除,使其不再进入工具策略快照、原生函数目录和系统提示词工具目录,DirectProject 的 `agc_tools` 规格与 bridge 执行入口同步拒绝。启用后立即恢复,不需要重启客户端。 +- 边界:导入扩展的启用状态仍走既有 `set_client_extension_enabled` 和扩展索引,不并入内置插件开关文件;内置插件开关不改变 manifest、权限或审计协议。 +- 验证:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭和“禁用后工具目录不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、导入 id 被拒绝、启用后回到 stopped。 diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md index 91bf4179a..e29ae9e2e 100644 --- a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -2,7 +2,7 @@ ## 目标 -目标是在用户已打开 Cocos Creator 项目时,由 AGC 识别正确的 Creator 主进程,并在进程内注入随包 JavaScript bootstrap;用户不需要在 Cocos 项目中手动安装扩展。桥接核心独立于 Tauri,位于 `server-rs/crates/cocos-editor-bridge`。2026-09-10 已验证通过 Node 自带的运行中 Inspector 激活入口完成引导,具体见本文“Inspector 注入调研”;这条路径尚未替换当前 crate 的 DLL 实现。 +目标是在用户已打开 Cocos Creator 项目时,由 AGC 识别正确的 Creator 主进程,并在进程内注入随包 JavaScript bootstrap;用户不需要在 Cocos 项目中手动安装扩展。桥接核心独立于 Tauri,随 Cocos 插件包分发,位于 `plugins/agc-cocos-editor/native/cocos-editor-bridge`。2026-09-10 已验证通过 Node 自带的运行中 Inspector 激活入口完成引导,具体见本文“Inspector 注入调研”;这条路径尚未替换当前 crate 的 DLL 实现。 ## 边界 @@ -20,7 +20,7 @@ Cocos Creator 3.x 是 Electron/Node 编辑器,不能复用 Unity Mono 的 Core crate 默认不启用任何宿主集成: ```toml -cocos-editor-bridge = { path = ".../server-rs/crates/cocos-editor-bridge", default-features = false, features = ["process-discovery"] } +cocos-editor-bridge = { path = ".../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false, features = ["process-discovery"] } ``` - `process-discovery`:启用 Windows Creator 主进程发现;不加载 Windows 注入 API。 @@ -29,11 +29,28 @@ cocos-editor-bridge = { path = ".../server-rs/crates/cocos-editor-bridge", defau AGC 或其它桌面宿主应将 `windows-injection` 作为单独的发行构建开关,服务端和非桌面构建保持 `default-features = false`。 -当前 AGC Tauri adapter 只暴露 `prepare_cocos_editor_injection`(确认前预检)、`inject_cocos_editor`(确认后注入)、`ping_cocos_editor`、`status_cocos_editor` 和 `execute_cocos_editor_code`。Runtime 只广告一个 `cocos.editor.execute` 工具,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;默认命令权限为 confirm,具体运行档沿用已有 Runtime 策略。进程发现留在 crate 内部作为目标校验步骤,不建立客户端扫描服务或独立发现入口。默认 AGC 构建不启用 Cocos 集成;桌面构建需显式传 `--features cocos-editor`,命令执行需传 `--features cocos-editor-execute`,注入构建再传 `--features cocos-editor-injection`。注入命令不接收 DLL 路径,只加载资源目录中的 `cocos-editor-bridge/cocos-editor-bridge.dll`,避免把 Tauri command 变成任意 DLL 注入器。 +AGC 不再内置 Cocos 专属 Tauri 命令。适配器 `cocos-editor` 由插件包 `plugins/agc-cocos-editor` 提供,实现通用 `EditorAdapter`(`prepare` / `inject` / `ping` / `status` / `execute` / `detect` / `connect` / `disconnect`),由宿主按 manifest 的 `adapter` 字段注册;插件入口通过 `host.rpc` 触发这些操作,宿主校验 `editor.rpc` 权限后路由到 native 模块。 + +Runtime 只广告一个 `cocos.editor.execute` 工具,代码输入使用当前项目根,目标 PID 由 crate 内部唯一匹配;默认命令权限为 confirm,具体运行档沿用已有 Runtime 策略。进程发现留在 crate 内部作为目标校验步骤,不建立客户端扫描服务或独立发现入口。默认 AGC 构建不启用 Cocos 集成;桌面构建需显式传 `--features cocos-editor`,命令执行需传 `--features cocos-editor-execute`,注入构建再传 `--features cocos-editor-injection`。注入只加载插件包 payload 目录里的 `cocos-editor-bridge.dll`(打包后位于 `/plugins/agc-cocos-editor/native/payload`),适配器拒绝任何其它路径,避免变成任意 DLL 注入器。 + +## 插件包形态 + +```text +plugins/agc-cocos-editor/ +├─ plugin.json Agent Plugins 清单 + AGC Runtime 扩展(adapter=cocos-editor) +├─ src/entry.mjs 运行时入口:注册命令 / 能力 / 面板,转发 host.rpc +├─ src/cocos-editor-adapter.mjs 通用请求 → 编辑器请求的翻译与入参校验 +├─ panels/cocos-editor.html 自包含面板 +└─ native/cocos-editor-bridge/ native 模块:进程发现、pipe 协议、注入、EditorAdapter 实现 +``` + +宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。 + +该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。禁用后插件进程停止且不能启动,Runtime 工具 `cocos.editor.execute` 与 DirectProject 的 `agc_cocos_execute` 同时从工具目录、工具策略快照和 Agent 上下文里消失;重新启用后立即恢复。开关状态保存在 AppData `extensions/builtin-plugins.json`。 ## 第一阶段命令协议 -DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用 crate,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义。 +DirectProject 的现役 `agc_tools` 目录通过 Windows `cocos-editor-execute` feature 注册 `agc_cocos_execute`,参数只有 `code`。客户端在 blocking worker 内调用插件 native 模块,保留项目锁和现有项目权限;当前 bridge 出现执行结果不确定后拒绝后续 execute。旧 Runtime 的对应工具名为 `cocos.editor.execute`,继续使用它已有的 pending action、权限和恢复语义;插件入口注册的同名命令走宿主 `host.rpc` → `EditorAdapter` 路径,两条路径共享同一 native 实现和不确定结果阻断语义。 注入 payload 在目标 Creator 主进程内监听 `\\.\pipe\genarrative-cocos-editor-{pid}`,使用换行分隔的 JSON。crate 只生成三种操作: diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 8703c0fd7..80a3234ec 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -11,8 +11,10 @@ AGC 插件系统由一个通用宿主和一个通用 SDK 组成。宿主统一 ```text apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs -apps/ai-game-creator-shell/src-tauri/src/editor_adapter/mod.rs +apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs packages/agc-plugin-sdk/src/index.ts +server-rs/crates/editor-adapter-api/src/lib.rs +plugins/agc-cocos-editor/ (第一个编辑器插件包) ``` 现有 DirectProject 的 Skill/MCP 导入仍保留。它们是 Codex 扩展注入链路,不等同于本宿主管理的可运行 AGC Plugin。 @@ -59,6 +61,21 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M 当前允许的权限为 `events.subscribe`、`project.read`、`editor.rpc`、`ui.register` 和 `capability.register`。未知权限、重复面板 id、非法入口和不支持的 API/适配器会使插件进入 `invalid` 状态,不启动进程。 +### plugins/ 工作区 + +除 AppData 导入外,宿主还扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的一级子目录是一个插件包。解析顺序为环境变量 `AGC_PLUGIN_WORKSPACE`、随包资源目录 `/plugins`、开发构建的仓库 `plugins/`。仓库工作区约定见 [`plugins/README.md`](../../../plugins/README.md)。 + +### 内置插件与可用开关 + +`plugins/` 工作区里的插件是**内置插件**:随客户端分发,用户不能卸载或删除,只能通过可用开关控制是否生效。开关状态持久化在 AppData `extensions/builtin-plugins.json`(`schemaVersion = agc.builtin-plugins.v1`,`enabled` 是 id 到布尔的映射);文件缺失按插件 manifest 的 `enabled` 处理,坏文件失败关闭。内置插件优先级高于同名导入插件,AppData 里的同名 Plugin 不会覆盖或间接卸载它。 + +开关同时驱动两处行为: + +1. 插件宿主:禁用时先停止运行中的插件进程,状态变为 `disabled`,`start_agc_plugin` 返回“插件已禁用”。内置插件在 `PluginSummary` / `AgcExtensionSummary` 里带 `builtin: true`,前端只显示可用开关,不显示重命名和卸载入口。 +2. Agent 工具面:禁用后对应 Runtime 工具从 `agent_runtime_executable_tools()` 里移除,因此不再进入工具策略快照(`autoTools` / `confirmTools` / `allowedTools`)、原生函数目录和系统提示词中的工具目录;DirectProject 的 `agc_tools` 规格同步移除,bridge 执行入口也会拒绝。启用后立即恢复,不需要重启客户端。 + +唯一的开关入口是 Tauri 命令 `set_agc_plugin_enabled`,它只接受登记过的内置插件 id;导入扩展继续使用既有 `set_client_extension_enabled`。 + ## 运行和 RPC 宿主以已安装插件目录为 cwd 启动入口;JavaScript 入口使用系统 `node` 执行,其它入口直接执行。环境先清空,再保留 PATH、Windows 系统目录和临时目录等必要变量,并注入插件身份和协议版本;不继承客户端凭据。Windows 复用进程模块的 Job Object,Unix 使用独立进程组,停止/卸载时回收自有进程。 @@ -82,14 +99,18 @@ host.rpc(method, params) ## 编辑器适配器扩展点 -`EditorAdapter` 只定义 `detect`、`connect`、`disconnect`、`translate_rpc` 和原生 `rpc`。宿主只保存适配器 registry,并把插件声明的适配器名称路由到对应实现;具体编辑器如何查找进程、校验 PID/项目/版本、建立连接和翻译编辑器消息,由后续适配器包独立实现。 +`EditorAdapter` 契约位于通用 crate `server-rs/crates/editor-adapter-api`,只定义 `detect`、`connect`、`disconnect`、`translate_rpc` 和原生 `rpc`。宿主只保存适配器 registry,并把插件声明的适配器名称路由到对应实现;具体编辑器如何查找进程、校验 PID/项目/版本、建立连接和翻译编辑器消息,由插件包自带模块实现。 -本次不内置任何目标编辑器适配器,也不包含编辑器专属进程名、注入逻辑或 Tauri 命令。新增适配器不会改变 Plugin 生命周期、SDK 或权限协议。 +宿主源码不包含编辑器专属进程名、注入逻辑或 Tauri 命令。第一个适配器 `cocos-editor` 由 `plugins/agc-cocos-editor` 提供:native 模块实现 `EditorAdapter`,由 `editor_adapters.rs` 在启动时按编译期链接注册。新增适配器不会改变 Plugin 生命周期、SDK 或权限协议。 + +当前 native 适配器仍由宿主在编译期链接(Cargo path 依赖);动态加载插件 native 模块不在本次范围,插件包格式与宿主协议不受此限制。 ## Tauri 命令 `list_agc_extensions` 返回统一的 Plugin/Skill/MCP catalog;`list_agc_plugins`、`refresh_agc_plugins`、`start_agc_plugin`、`stop_agc_plugin`、`reload_agc_plugin`、`call_agc_plugin` 和 `read_agc_plugin_panel` 提供 Runtime Plugin 管理入口;`set_agc_plugin_project_path` 设置当前项目的受控上下文。编辑器适配器通过宿主 registry 和 Plugin RPC 使用,不增加编辑器专属 Tauri 命令。 +编辑器操作统一走 `host.rpc`:插件用 `extensions.world.genarrative.agc.adapter` 或显式 `adapter` 参数选择适配器,宿主校验 `editor.rpc` 权限后调用 `EditorAdapter::rpc`。项目上下文通过 `host.events.subscribe` 的响应和 `project.changed` 事件 payload 下发,插件不需要自己扫描目录。 + 每次启停、RPC 成功/失败和权限拒绝都追加到 AppData `extensions/audit.jsonl`,日志只写插件 id、动作、结果和固定错误摘要,不写 API Key、Cookie、Token 或宿主绝对路径。 ## 验收门禁 @@ -101,6 +122,8 @@ OpenAI 官方 Plugins 文档将 Skills、MCP Server 和可选 UI 定义为同一 - Rust:manifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 registry 边界单测;`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 - 前端:`agc-plugin-sdk` TypeScript 编译、宿主服务类型检查,以及 `PluginPanelHost` 的挂载/卸载测试。 +- 内置插件开关:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭,以及“禁用后工具目录里不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、启用后回到 stopped。 +- 插件工作区:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 覆盖工作区扫描与 manifest 启用状态;`cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml` 覆盖 Cocos 适配器;`node --test plugins/agc-cocos-editor/src/entry.test.mjs` 覆盖插件入口协议与 manifest 一致性。 - 通用仓库门禁:`npm run check:encoding`、`git diff --check`;发布前仍需单独执行 AGC package smoke 和安装包 smoke。 -当前版本完成统一扩展 catalog、通用宿主、SDK、面板宿主和通用 EditorAdapter registry;目标编辑器适配器属于后续独立实现,不用未验证的连接状态替代真实编辑器验收。 +当前版本完成统一扩展 catalog、通用宿主、SDK、面板宿主、通用 EditorAdapter registry 和 `plugins/` 工作区;`agc-cocos-editor` 是第一个插件包。真实编辑器验收仍按 Cocos 方案文档单独执行,不用未验证的连接状态替代。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index adc5b85cb..7a58b9c82 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -183,7 +183,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 ## 目标 -在 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 内嵌预览、云同步或插件市场。新增的 Cocos Creator bridge 核心独立为 `server-rs/crates/cocos-editor-bridge`,AGC 仅通过 feature 转发桌面进程发现、受控 execute 和 Windows 注入能力;它不改变服务端路线,也不把原始 pipe、句柄或未绑定项目身份的代码执行面暴露给 Agent。 +在 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 内嵌预览、云同步或插件市场。新增的 Cocos Creator bridge 核心独立为插件 `plugins/agc-cocos-editor`(native 模块位于其 `native/cocos-editor-bridge`),AGC 仅通过通用插件宿主和 feature 转发桌面进程发现、受控 execute 和 Windows 注入能力;它不改变服务端路线,也不把原始 pipe、句柄或未绑定项目身份的代码执行面暴露给 Agent。 ## 技术选择 diff --git a/package-lock.json b/package-lock.json index 428ad5c34..d81e19d56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "packages/image-canvas-react", "packages/agc-plugin-sdk", "packages/shared", + "plugins/agc-cocos-editor", "tools/spine-json-export-validator" ], "dependencies": { @@ -5141,6 +5142,10 @@ "resolved": "apps/admin-web", "link": true }, + "node_modules/@genarrative/agc-plugin-cocos-editor": { + "resolved": "plugins/agc-cocos-editor", + "link": true + }, "node_modules/@genarrative/agc-plugin-sdk": { "resolved": "packages/agc-plugin-sdk", "link": true @@ -22927,6 +22932,13 @@ "react-dom": "^19.0.0" } }, + "plugins/agc-cocos-editor": { + "name": "@genarrative/agc-plugin-cocos-editor", + "version": "0.1.0", + "dependencies": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } + }, "tools/spine-json-export-validator": { "name": "@genarrative/spine-json-export-validator", "version": "0.1.0", @@ -26302,6 +26314,12 @@ "vitest": "^0.34.6" } }, + "@genarrative/agc-plugin-cocos-editor": { + "version": "file:plugins/agc-cocos-editor", + "requires": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } + }, "@genarrative/agc-plugin-sdk": { "version": "file:packages/agc-plugin-sdk" }, diff --git a/package.json b/package.json index d8617556f..f955cbef2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/image-canvas-react", "packages/agc-plugin-sdk", "packages/shared", + "plugins/agc-cocos-editor", "tools/spine-json-export-validator" ], "scripts": { @@ -162,6 +163,9 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", + "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs", + "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml", + "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", "agc:check": "npm run ai-game-creator-shell:check", "agc:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", "agc:test": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --", diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..a36f86177 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,70 @@ +# AGC 插件工作区 + +本目录是 AGC 插件的工作区,每个一级子目录是一个 **Agent Plugins 包**,随 AGC 客户端分发。 + +```text +plugins/ +└─ agc-cocos-editor/ + ├─ plugin.json Agent Plugins 标准清单 + AGC Runtime 扩展 + ├─ package.json npm workspace 成员,声明 SDK 版本契约 + ├─ panels/ 自包含面板 HTML + ├─ src/ 运行时入口与适配器翻译 + └─ native/ 插件自带 native 模块(Cargo 包) +``` + +## 清单格式 + +`plugin.json` 使用 OpenAI Agent Plugins 标准 schema,AGC 专属字段放在 +`extensions.world.genarrative.agc`: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "agc-cocos-editor", + "version": "0.1.0", + "extensions": { + "com.openai": { + "interface": { "displayName": "Cocos Creator 编辑器桥接" } + }, + "world.genarrative.agc": { + "apiVersion": "v1", + "entry": "./src/entry.mjs", + "adapter": "cocos-editor", + "permissions": [ + "events.subscribe", + "editor.rpc", + "ui.register", + "capability.register" + ], + "panels": [] + } + } +} +``` + +约束与通用宿主一致:插件目录内最多一个 Plugin;`entry` 必须是包内普通文件; +未知权限、非法入口或不支持的 `apiVersion` 会让插件进入 `invalid` 状态,不启动进程。 + +## 宿主如何加载 + +宿主按以下顺序解析插件工作区,任一命中即生效: + +1. 环境变量 `AGC_PLUGIN_WORKSPACE`(本地联调与测试)。 +2. 随包资源目录 `/plugins`(安装包)。 +3. 开发构建的仓库 `plugins/` 目录。 + +工作区插件是**内置插件**:随客户端分发、不能卸载,只能通过可用开关控制是否生效。 +开关状态保存在 AppData `extensions/builtin-plugins.json`,内置插件优先级高于同名 +导入插件,不会被 AppData 覆盖或删除。插件运行入口由宿主以插件目录为 cwd 启动 +(`.mjs` 用系统 `node`,其它入口直接执行),stdio 上使用一行一个 JSON-RPC 2.0 消息。 + +## 新增插件 + +1. 新建 `plugins//`,写 `plugin.json`,`name` 与目录名保持一致。 +2. 运行时插件提供 `src/entry.mjs`,按 `agc.plugin.v1` 协议注册命令、面板和能力; + 仅打包 Skill/MCP 的插件可以没有 `entry`,宿主会按 `package` 状态展示。 +3. 需要编辑器原生能力时,在 `native/` 下放插件自己的 Cargo 包,并实现 + `editor-adapter-api` 的 `EditorAdapter`;`plugin.json` 的 `adapter` 字段必须与 + `EditorAdapter::id()` 一致。 +4. 在根 `package.json`、`scripts/check-npm-workspaces.mjs` 登记 workspace 成员。 +5. 更新 `docs/technical/` 里的插件或编辑器方案文档。 diff --git a/plugins/agc-cocos-editor/README.md b/plugins/agc-cocos-editor/README.md new file mode 100644 index 000000000..adb9df1f0 --- /dev/null +++ b/plugins/agc-cocos-editor/README.md @@ -0,0 +1,65 @@ +# agc-cocos-editor + +Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插件:插件生命周期、UI、 +RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。 + +它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。用户在运行时设置里只能切换 +“是否可用”,禁用后插件不能启动,`cocos.editor.execute` / `agc_cocos_execute` 也会 +从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后立即恢复。 + +```text +plugin.json Agent Plugins 清单 + AGC Runtime 扩展 +src/entry.mjs 运行时入口(注册命令 / 能力 / 面板,转发 host.rpc) +src/cocos-editor-adapter.mjs 通用请求 → Cocos 适配器请求的翻译与入参校验 +panels/cocos-editor.html 自包含面板 +native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pipe 协议、注入) +``` + +## Runtime 契约 + +| 项 | 值 | +| --------- | ---------------------------------------------------------------------------------- | +| 协议 | `agc.plugin.v1`(stdio 行分隔 JSON-RPC 2.0) | +| 适配器 id | `cocos-editor` | +| 命令 | `cocos.editor.execute`(`{ code }`) | +| 能力 | `cocos.editor.connection`(`{ operation, processId?, projectPath?, timeoutMs? }`) | +| 面板 | `cocos-editor`(`panels/cocos-editor.html`,sidebar) | + +`operation` 取值为 `detect`、`connect`、`disconnect`、`prepare`、`ping`、`status`、 +`execute`、`inject`,与 native 适配器的 `COCOS_EDITOR_RPC_METHODS` 一一对应; +`src/entry.test.mjs` 会校验两边不会漂移。 + +## 项目上下文 + +插件从宿主获得当前受控项目路径: + +- 注册 `host.events.subscribe { type: 'project.changed' }` 时,宿主在响应里返回当前 + `projectPath`。 +- 之后宿主设置项目时推送 `project.changed` 事件,payload 带 `projectPath`。 + +插件不缓存凭据、不扫描文件系统;所有编辑器操作都经 `host.rpc` 交给 native 适配器, +由适配器做 PID / 项目 / 版本校验。 + +## Native 模块 + +`native/cocos-editor-bridge` 是从 AGC 服务端源码树移入本插件包的独立 crate,实现 +`editor-adapter-api::EditorAdapter`: + +- `process-discovery`:只把 `CocosCreator.exe` 主进程的 PID、`--project` 和 Creator + 版本绑定起来,排除 Electron 子进程。 +- `windows-transport`:注入后通过 named pipe 提供 `ping/status/execute`,执行结果 + 不确定时返回 `ExecutionUncertain` 并禁止自动重放。 +- `windows-injection`:随包 DLL 的 Windows 注入实现,默认关闭。 + +AGC 客户端当前在编译期链接本 crate(Cargo path 依赖),由通用宿主按 manifest 的 +`adapter` 字段注册;宿主源码里没有 Cocos 进程名、注入或 Editor.Message 逻辑。 + +## 本地验证 + +```bash +cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml +node --test plugins/agc-cocos-editor/src/entry.test.mjs +``` + +真实 Creator 验收(注入、Inspector 引导、非空场景读写)仍按 +`docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md` 单独执行。 diff --git a/server-rs/crates/cocos-editor-bridge/.gitignore b/plugins/agc-cocos-editor/native/cocos-editor-bridge/.gitignore similarity index 100% rename from server-rs/crates/cocos-editor-bridge/.gitignore rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/.gitignore diff --git a/server-rs/crates/cocos-editor-bridge/Cargo.toml b/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml similarity index 93% rename from server-rs/crates/cocos-editor-bridge/Cargo.toml rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml index ea75061bf..e79717b14 100644 --- a/server-rs/crates/cocos-editor-bridge/Cargo.toml +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml @@ -35,6 +35,7 @@ windows-injection = [ ] [dependencies] +editor-adapter-api = { path = "../../../../server-rs/crates/editor-adapter-api" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = { version = "0.10", optional = true } diff --git a/server-rs/crates/cocos-editor-bridge/build.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs similarity index 100% rename from server-rs/crates/cocos-editor-bridge/build.rs rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/build.rs diff --git a/server-rs/crates/cocos-editor-bridge/native/native_payload.cpp b/plugins/agc-cocos-editor/native/cocos-editor-bridge/native/native_payload.cpp similarity index 100% rename from server-rs/crates/cocos-editor-bridge/native/native_payload.cpp rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/native/native_payload.cpp diff --git a/server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/payload/bootstrap.cjs similarity index 100% rename from server-rs/crates/cocos-editor-bridge/payload/bootstrap.cjs rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/payload/bootstrap.cjs diff --git a/server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/payload/bootstrap.test.cjs similarity index 100% rename from server-rs/crates/cocos-editor-bridge/payload/bootstrap.test.cjs rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/payload/bootstrap.test.cjs diff --git a/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs new file mode 100644 index 000000000..636b76064 --- /dev/null +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/adapter.rs @@ -0,0 +1,419 @@ +//! Cocos Creator 专属编辑器适配器。 +//! +//! 该实现随 Cocos 插件包分发,实现通用的 `editor_adapter_api::EditorAdapter`, +//! 由 AGC 宿主按插件 manifest 里的 `adapter` 字段注册和路由。宿主本身不包含 +//! Cocos 进程名、注入或 Editor.Message 知识。 + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + discover_cocos_editors, execute_cocos_editor_code, execute_cocos_editor_code_for_project, + inject_bridge_dll, normalize_existing_directory, paths_equal, ping_cocos_editor, + status_cocos_editor, validate_execute_code, validate_injection_target, + CocosEditorInjectionRequest, DEFAULT_COMMAND_TIMEOUT_MS, +}; + +pub const COCOS_EDITOR_ADAPTER_ID: &str = "cocos-editor"; + +/// 通用 RPC 方法名到适配器方法的别名表。 +pub const COCOS_EDITOR_RPC_METHODS: &[(&str, &str)] = &[ + ("editor.detect", "detect"), + ("editor.connect", "connect"), + ("editor.disconnect", "disconnect"), + ("editor.prepare", "prepare"), + ("editor.ping", "ping"), + ("editor.status", "status"), + ("editor.execute", "execute"), + ("editor.inject", "inject"), +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CocosEditorConnection { + process_id: u32, + project_path: String, + creator_version: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AdapterRpcParams { + #[serde(default)] + process_id: Option, + #[serde(default)] + project_path: Option, + #[serde(default)] + code: Option, + #[serde(default)] + payload_path: Option, + #[serde(default)] + timeout_ms: Option, +} + +impl AdapterRpcParams { + fn from_value(params: Value) -> Result { + if params.is_null() { + return Ok(Self::default()); + } + serde_json::from_value(params).map_err(|error| format!("插件 RPC 参数无效:{error}")) + } + + fn timeout_ms(&self) -> u32 { + self.timeout_ms + .unwrap_or(DEFAULT_COMMAND_TIMEOUT_MS) + .clamp(1, 60_000) + } + + fn project_path(&self) -> Result { + self.project_path + .clone() + .ok_or_else(|| "缺少 projectPath".to_string()) + } + + fn process_id(&self) -> Result { + self.process_id.ok_or_else(|| "缺少 processId".to_string()) + } +} + +/// 由插件包自带的 native 模块实现的 Cocos Creator 适配器。 +#[derive(Debug)] +pub struct CocosEditorAdapter { + payload_candidates: Vec, + connection: Mutex>, +} + +impl Default for CocosEditorAdapter { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +impl CocosEditorAdapter { + pub fn new(payload_candidates: Vec) -> Self { + Self { + payload_candidates, + connection: Mutex::new(None), + } + } + + fn connection(&self) -> Result, String> { + self.connection + .lock() + .map(|connection| connection.clone()) + .map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string()) + } + + fn connected_info(&self, process: &crate::CocosEditorProcess) -> EditorConnectionInfo { + EditorConnectionInfo { + adapter: COCOS_EDITOR_ADAPTER_ID.to_string(), + connected: true, + pid: Some(process.process_id), + project_path: process.project_path.clone(), + version: process.creator_version.clone(), + } + } + + /// 只允许加载插件包随包目录里的 payload,避免适配器变成任意 DLL 注入器。 + fn payload_path(&self, requested: Option<&str>) -> Result { + match requested { + Some(requested) => { + let requested = PathBuf::from(requested); + match requested + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) if extension.eq_ignore_ascii_case("dll") => {} + _ => return Err("Cocos bridge payload 必须是 .dll".to_string()), + } + let normalized = requested + .canonicalize() + .map_err(|_| "Cocos bridge payload 不可读".to_string())?; + let allowed = self.payload_candidates.iter().any(|candidate| { + candidate + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .is_some_and(|root| normalized.starts_with(root)) + }); + if !allowed { + return Err("Cocos bridge payload 只能来自插件包随包目录".to_string()); + } + Ok(normalized) + } + None => self + .payload_candidates + .iter() + .find(|candidate| candidate.is_file()) + .cloned() + .ok_or_else(|| "插件未随包提供 Cocos bridge payload".to_string()), + } + } + + fn project_connection( + &self, + project_path: &str, + ) -> Result, String> { + let connection = self.connection()?; + match connection { + Some(connection) + if paths_equal(Path::new(&connection.project_path), Path::new(project_path)) => + { + Ok(Some(connection)) + } + Some(_) => Err("适配器已连接到其它项目".to_string()), + None => Ok(None), + } + } + + fn rpc_prepare(&self, params: &AdapterRpcParams) -> Result { + let project_path = params.project_path()?; + let process = validate_injection_target(params.process_id()?, &project_path) + .map_err(|error| error.to_string())?; + serde_json::to_value(process).map_err(|error| error.to_string()) + } + + fn rpc_execute(&self, params: &AdapterRpcParams) -> Result { + let project_path = params.project_path()?; + let code = params.code.clone().ok_or_else(|| "缺少 code".to_string())?; + validate_execute_code(&code).map_err(|error| error.to_string())?; + let timeout_ms = params.timeout_ms(); + let response = match self.project_connection(&project_path)? { + Some(connection) => execute_cocos_editor_code( + connection.process_id, + &connection.project_path, + &code, + timeout_ms, + ), + None => execute_cocos_editor_code_for_project(&project_path, &code, timeout_ms), + } + .map_err(|error| error.to_string())?; + serde_json::to_value(response).map_err(|error| error.to_string()) + } + + fn rpc_inject(&self, params: &AdapterRpcParams) -> Result { + let project_path = params.project_path()?; + let payload = self.payload_path(params.payload_path.as_deref())?; + let request = CocosEditorInjectionRequest { + process_id: params.process_id()?, + project_path, + bridge_dll_path: payload.to_string_lossy().into_owned(), + timeout_ms: params + .timeout_ms + .unwrap_or(crate::DEFAULT_INJECTION_TIMEOUT_MS), + }; + let result = inject_bridge_dll(&request).map_err(|error| error.to_string())?; + serde_json::to_value(result).map_err(|error| error.to_string()) + } +} + +fn canonical_method(method: &str) -> Option<&'static str> { + let trimmed = method.trim(); + if let Some((_, canonical)) = COCOS_EDITOR_RPC_METHODS + .iter() + .find(|(alias, _)| *alias == trimmed) + { + return Some(canonical); + } + COCOS_EDITOR_RPC_METHODS + .iter() + .find(|(_, canonical)| *canonical == trimmed) + .map(|(_, canonical)| *canonical) +} + +impl EditorAdapter for CocosEditorAdapter { + fn id(&self) -> &'static str { + COCOS_EDITOR_ADAPTER_ID + } + + fn detect(&self, project_path: &Path) -> Result { + let project_path = + normalize_existing_directory(project_path).map_err(|error| error.to_string())?; + let discovery = discover_cocos_editors().map_err(|error| error.to_string())?; + let mut matches = discovery.processes.into_iter().filter(|process| { + process.project_path.as_deref().is_some_and(|candidate| { + paths_equal(Path::new(candidate), Path::new(&project_path)) + }) + }); + let target = matches + .next() + .ok_or_else(|| "当前项目没有已打开的 Cocos Creator 主进程".to_string())?; + if matches.next().is_some() { + return Err("当前项目匹配到多个 Cocos Creator 主进程".to_string()); + } + Ok(EditorConnectionInfo { + adapter: COCOS_EDITOR_ADAPTER_ID.to_string(), + connected: false, + pid: Some(target.process_id), + project_path: target.project_path, + version: target.creator_version, + }) + } + + fn connect( + &mut self, + pid: u32, + project_path: &Path, + _version: &str, + ) -> Result { + let process = validate_injection_target(pid, &project_path.to_string_lossy()) + .map_err(|error| error.to_string())?; + let connection = CocosEditorConnection { + process_id: process.process_id, + project_path: process + .project_path + .clone() + .unwrap_or_else(|| project_path.to_string_lossy().into_owned()), + creator_version: process.creator_version.clone(), + }; + let info = self.connected_info(&process); + *self + .connection + .lock() + .map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())? = Some(connection); + Ok(info) + } + + fn disconnect(&mut self) { + if let Ok(mut connection) = self.connection.lock() { + *connection = None; + } + } + + fn translate_rpc(&self, method: &str, params: Value) -> Result { + let canonical = canonical_method(method) + .ok_or_else(|| format!("Cocos 适配器不支持 RPC 方法:{method}"))?; + Ok(json!({ "method": canonical, "params": params })) + } + + fn rpc(&self, method: &str, params: Value) -> Result { + let canonical = canonical_method(method) + .ok_or_else(|| format!("Cocos 适配器不支持 RPC 方法:{method}"))?; + let params = AdapterRpcParams::from_value(params)?; + match canonical { + "detect" => { + let project_path = params.project_path()?; + let info = self.detect(Path::new(&project_path))?; + serde_json::to_value(info).map_err(|error| error.to_string()) + } + "connect" => { + let project_path = params.project_path()?; + let process = validate_injection_target(params.process_id()?, &project_path) + .map_err(|error| error.to_string())?; + let connection = CocosEditorConnection { + process_id: process.process_id, + project_path: process + .project_path + .clone() + .unwrap_or_else(|| project_path.clone()), + creator_version: process.creator_version.clone(), + }; + let info = self.connected_info(&process); + *self + .connection + .lock() + .map_err(|_| "Cocos 适配器连接状态锁已损坏".to_string())? = Some(connection); + serde_json::to_value(info).map_err(|error| error.to_string()) + } + "disconnect" => { + if let Ok(mut connection) = self.connection.lock() { + *connection = None; + } + Ok(json!({ "connected": false })) + } + "prepare" => self.rpc_prepare(¶ms), + "ping" | "status" => { + let project_path = params.project_path()?; + let process_id = params.process_id()?; + let timeout_ms = params.timeout_ms(); + let response = if canonical == "ping" { + ping_cocos_editor(process_id, &project_path, timeout_ms) + } else { + status_cocos_editor(process_id, &project_path, timeout_ms) + } + .map_err(|error| error.to_string())?; + serde_json::to_value(response).map_err(|error| error.to_string()) + } + "execute" => self.rpc_execute(¶ms), + "inject" => self.rpc_inject(¶ms), + other => Err(format!("Cocos 适配器不支持 RPC 方法:{other}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapter_id_matches_plugin_manifest_adapter() { + let adapter = CocosEditorAdapter::default(); + assert_eq!(adapter.id(), "cocos-editor"); + } + + #[test] + fn translate_rpc_accepts_aliases_and_rejects_unknown_methods() { + let adapter = CocosEditorAdapter::default(); + let translated = adapter + .translate_rpc("editor.execute", json!({"code": "return 1;"})) + .expect("alias must translate"); + assert_eq!(translated["method"], "execute"); + assert!(adapter.translate_rpc("editor.eval", json!({})).is_err()); + assert!(adapter.rpc("editor.unknown", json!({})).is_err()); + } + + #[test] + fn execute_validates_code_before_touching_the_editor() { + let adapter = CocosEditorAdapter::default(); + let error = adapter + .rpc("execute", json!({"projectPath": "C:\\demo", "code": " "})) + .expect_err("empty code must fail"); + assert!(error.contains("不能为空"), "unexpected error: {error}"); + } + + #[test] + fn connect_rejects_process_id_zero() { + let adapter = CocosEditorAdapter::default(); + let error = adapter + .rpc( + "connect", + json!({"processId": 0, "projectPath": "C:\\demo"}), + ) + .expect_err("processId 0 must fail"); + assert!(error.contains("processId"), "unexpected error: {error}"); + } + + #[test] + fn detect_fails_closed_for_missing_project_directory() { + let adapter = CocosEditorAdapter::default(); + let missing = std::env::temp_dir().join("agc-cocos-adapter-missing-project"); + let error = adapter + .detect(&missing) + .expect_err("missing project must fail closed"); + assert!(!error.is_empty()); + } + + #[test] + fn inject_rejects_payload_outside_the_plugin_package() { + let adapter = CocosEditorAdapter::default(); + let outside = std::env::temp_dir().join("agc-cocos-adapter-outside-payload.dll"); + std::fs::write(&outside, b"not-a-real-payload").expect("write probe payload"); + let error = adapter + .rpc( + "inject", + json!({ + "processId": 12, + "projectPath": "C:\\demo", + "payloadPath": outside.to_string_lossy(), + }), + ) + .expect_err("payload outside the plugin package must fail"); + let _ = std::fs::remove_file(&outside); + assert!( + error.contains("只能来自插件包随包目录"), + "unexpected error: {error}" + ); + } +} diff --git a/server-rs/crates/cocos-editor-bridge/src/lib.rs b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs similarity index 99% rename from server-rs/crates/cocos-editor-bridge/src/lib.rs rename to plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs index 820e9c726..d6520f885 100644 --- a/server-rs/crates/cocos-editor-bridge/src/lib.rs +++ b/plugins/agc-cocos-editor/native/cocos-editor-bridge/src/lib.rs @@ -20,6 +20,10 @@ use serde::{Deserialize, Serialize}; #[cfg(all(feature = "windows-injection", windows))] use sha2::{Digest, Sha256}; + +mod adapter; + +pub use adapter::{CocosEditorAdapter, COCOS_EDITOR_ADAPTER_ID, COCOS_EDITOR_RPC_METHODS}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; diff --git a/plugins/agc-cocos-editor/package.json b/plugins/agc-cocos-editor/package.json new file mode 100644 index 000000000..05bb39822 --- /dev/null +++ b/plugins/agc-cocos-editor/package.json @@ -0,0 +1,13 @@ +{ + "name": "@genarrative/agc-plugin-cocos-editor", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "AGC Cocos Creator 编辑器插件", + "scripts": { + "test": "node --test src/entry.test.mjs" + }, + "dependencies": { + "@genarrative/agc-plugin-sdk": "0.1.0" + } +} diff --git a/plugins/agc-cocos-editor/panels/cocos-editor.html b/plugins/agc-cocos-editor/panels/cocos-editor.html new file mode 100644 index 000000000..06ed67db8 --- /dev/null +++ b/plugins/agc-cocos-editor/panels/cocos-editor.html @@ -0,0 +1,74 @@ + + + + + Cocos Creator + + + +

Cocos Creator

+
+
+
插件
+
agc-cocos-editor
+
适配器
+
cocos-editor
+
命令
+
cocos.editor.execute
+
能力
+
cocos.editor.connection
+
+
+ + diff --git a/plugins/agc-cocos-editor/plugin.json b/plugins/agc-cocos-editor/plugin.json new file mode 100644 index 000000000..c4d654ab1 --- /dev/null +++ b/plugins/agc-cocos-editor/plugin.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "agc-cocos-editor", + "version": "0.1.0", + "description": "在用户已打开的 Cocos Creator 项目中执行受控编辑器操作", + "extensions": { + "com.openai": { + "interface": { + "displayName": "Cocos Creator 编辑器桥接" + } + }, + "world.genarrative.agc": { + "apiVersion": "v1", + "entry": "./src/entry.mjs", + "adapter": "cocos-editor", + "permissions": [ + "events.subscribe", + "editor.rpc", + "ui.register", + "capability.register" + ], + "panels": [ + { + "id": "cocos-editor", + "title": "Cocos Creator", + "entry": "./panels/cocos-editor.html", + "placement": "sidebar" + } + ] + } + } +} diff --git a/plugins/agc-cocos-editor/src/cocos-editor-adapter.mjs b/plugins/agc-cocos-editor/src/cocos-editor-adapter.mjs new file mode 100644 index 000000000..36b06bb44 --- /dev/null +++ b/plugins/agc-cocos-editor/src/cocos-editor-adapter.mjs @@ -0,0 +1,102 @@ +/** + * Cocos 插件侧适配器翻译层。 + * + * 该模块只做“通用插件请求 → Cocos 适配器请求”的翻译与入参校验,不直接接触 + * 进程、DLL 或 named pipe;原生部分由插件包内的 native/cocos-editor-bridge + * 实现,并通过宿主 `host.rpc` 路由过去。 + */ + +export const COCOS_EDITOR_ADAPTER_ID = 'cocos-editor'; + +/** 与 native crate 的 MAX_EXECUTE_CODE_BYTES 保持一致。 */ +export const COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES = 128 * 1024; + +export const COCOS_EDITOR_MAX_TIMEOUT_MS = 60_000; + +export const COCOS_EDITOR_OPERATIONS = Object.freeze([ + 'detect', + 'connect', + 'disconnect', + 'prepare', + 'ping', + 'status', + 'execute', + 'inject', +]); + +const REQUIRED_PARAMS = Object.freeze({ + detect: ['projectPath'], + connect: ['processId', 'projectPath'], + disconnect: [], + prepare: ['processId', 'projectPath'], + ping: ['processId', 'projectPath'], + status: ['processId', 'projectPath'], + execute: ['projectPath', 'code'], + inject: ['processId', 'projectPath'], +}); + +export function validateExecuteCode(code) { + if (typeof code !== 'string' || code.trim().length === 0) { + throw new Error('execute.code 不能为空'); + } + if (code.includes('\u0000')) { + throw new Error('execute.code 不能包含 NUL'); + } + const bytes = Buffer.byteLength(code, 'utf8'); + if (bytes > COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES) { + throw new Error( + `execute.code 超过 ${COCOS_EDITOR_MAX_EXECUTE_CODE_BYTES} 字节上限`, + ); + } +} + +function assertTimeout(timeoutMs) { + if (timeoutMs === undefined || timeoutMs === null) return undefined; + if ( + !Number.isInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > COCOS_EDITOR_MAX_TIMEOUT_MS + ) { + throw new Error(`timeoutMs 必须在 1..=${COCOS_EDITOR_MAX_TIMEOUT_MS}`); + } + return timeoutMs; +} + +/** + * 生成 `host.rpc` 的编辑器请求。 + * + * @param {string} operation COCOS_EDITOR_OPERATIONS 之一 + * @param {object} [rawParams] projectPath / processId / code / timeoutMs / payloadPath + */ +export function buildEditorRpcRequest(operation, rawParams = {}) { + if (!COCOS_EDITOR_OPERATIONS.includes(operation)) { + throw new Error(`不支持的 Cocos 适配器操作:${operation}`); + } + const required = REQUIRED_PARAMS[operation] ?? []; + const params = {}; + for (const name of required) { + const value = rawParams[name]; + // code 由 validateExecuteCode 给出更精确的空值 / 长度 / NUL 诊断。 + if ( + value === undefined || + value === null || + (name !== 'code' && value === '') + ) { + throw new Error(`Cocos 适配器操作 ${operation} 缺少 ${name}`); + } + params[name] = value; + } + if (operation === 'execute') { + validateExecuteCode(rawParams.code); + params.code = rawParams.code; + } + const timeoutMs = assertTimeout(rawParams.timeoutMs); + if (timeoutMs !== undefined) params.timeoutMs = timeoutMs; + if (typeof rawParams.payloadPath === 'string' && rawParams.payloadPath) { + if (operation !== 'inject') { + throw new Error('payloadPath 只允许用于 inject'); + } + params.payloadPath = rawParams.payloadPath; + } + return { method: `editor.${operation}`, params }; +} diff --git a/plugins/agc-cocos-editor/src/entry.mjs b/plugins/agc-cocos-editor/src/entry.mjs new file mode 100644 index 000000000..8d358ecfa --- /dev/null +++ b/plugins/agc-cocos-editor/src/entry.mjs @@ -0,0 +1,231 @@ +/** + * AGC Cocos Creator 插件运行时入口。 + * + * 宿主以插件目录为 cwd 用系统 `node` 启动本文件,stdio 上使用一行一个 JSON-RPC + * 2.0 消息(`agc.plugin.v1`)。入口只做注册与转发:命令、能力、面板通过通用宿主 + * 注册,编辑器操作经 `host.rpc` 路由到插件包自带 native 适配器。 + * + * 说明:通用 SDK `@genarrative/agc-plugin-sdk` 目前以 TypeScript 源码分发,而运行 + * 入口必须能被系统 node 直接执行,因此这里内联同一份 stdio 协议实现;package.json + * 仍声明 SDK 版本依赖,测试会校验协议常量、方法名与 native 适配器一致。 + */ + +import { createInterface } from 'node:readline'; +import { pathToFileURL } from 'node:url'; + +import { + buildEditorRpcRequest, + COCOS_EDITOR_OPERATIONS, + validateExecuteCode, +} from './cocos-editor-adapter.mjs'; + +export const COCOS_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1'; +export const COCOS_EXECUTE_COMMAND_ID = 'cocos.editor.execute'; +export const COCOS_CONNECTION_CAPABILITY_ID = 'cocos.editor.connection'; +export const COCOS_EDITOR_PANEL = Object.freeze({ + id: 'cocos-editor', + title: 'Cocos Creator', + entry: './panels/cocos-editor.html', + placement: 'sidebar', +}); +export const PROJECT_CHANGED_EVENT = 'project.changed'; + +const RPC_TIMEOUT_MS = 10_000; + +export function createCocosEditorPlugin({ + send, + log = () => undefined, + timeoutMs = RPC_TIMEOUT_MS, +}) { + let nextId = 1; + let activeProjectPath = null; + let disposed = false; + const pending = new Map(); + + const handlers = new Map([ + [COCOS_EXECUTE_COMMAND_ID, handleExecute], + [COCOS_CONNECTION_CAPABILITY_ID, handleConnection], + ]); + + function request(method, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`插件宿主 RPC 超时:${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + send({ jsonrpc: '2.0', id, method, params }); + }); + } + + function respond(id, result, error) { + if (error) { + send({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: error }, + }); + return; + } + send({ jsonrpc: '2.0', id, result: result ?? null }); + } + + function resolveProjectPath(params) { + const explicit = params?.projectPath; + if (typeof explicit === 'string' && explicit) return explicit; + if (activeProjectPath) return activeProjectPath; + throw new Error('当前没有受控项目路径,宿主需先设置项目上下文'); + } + + async function callEditor(operation, params) { + const { method, params: editorParams } = buildEditorRpcRequest( + operation, + params, + ); + return request('host.rpc', { method, params: editorParams }); + } + + async function handleExecute(params) { + const code = params?.code; + validateExecuteCode(code); + const response = await callEditor('execute', { + projectPath: resolveProjectPath(params), + code, + }); + return { + status: response?.ok ? 'completed' : 'failed', + response, + }; + } + + async function handleConnection(params) { + const operation = params?.operation ?? 'detect'; + if (!COCOS_EDITOR_OPERATIONS.includes(operation)) { + throw new Error(`不支持的能力操作:${operation}`); + } + if (operation === 'disconnect') { + return callEditor('disconnect', {}); + } + return callEditor(operation, { + projectPath: resolveProjectPath(params), + processId: params?.processId, + timeoutMs: params?.timeoutMs, + payloadPath: params?.payloadPath, + }); + } + + async function handleMessage(message) { + if (disposed) return; + const envelope = + typeof message === 'string' ? JSON.parse(message) : message; + if (!envelope || envelope.jsonrpc !== '2.0') return; + + if (envelope.method === 'host.event') { + const event = envelope.params ?? {}; + if (event.type === PROJECT_CHANGED_EVENT) { + const projectPath = event.payload?.projectPath; + activeProjectPath = + typeof projectPath === 'string' && projectPath ? projectPath : null; + } + return; + } + + if (envelope.method !== undefined) { + if (envelope.id === undefined) return; + const handler = handlers.get(envelope.method); + if (!handler) { + respond(envelope.id, undefined, `插件未注册方法:${envelope.method}`); + return; + } + try { + respond(envelope.id, await handler(envelope.params ?? {})); + } catch (error) { + log(`cocos plugin 处理 ${envelope.method} 失败:${error.message}`); + respond(envelope.id, undefined, error.message); + } + return; + } + + const request = pending.get(envelope.id); + if (!request) return; + pending.delete(envelope.id); + clearTimeout(request.timer); + if (envelope.error) { + request.reject(new Error(envelope.error.message ?? '插件宿主 RPC 失败')); + return; + } + request.resolve(envelope.result); + } + + async function start() { + const command = await request('host.registerCommand', { + id: COCOS_EXECUTE_COMMAND_ID, + title: '在 Cocos Creator 中执行代码', + description: '在已打开的 Cocos Creator 项目中执行受控 JavaScript 函数体', + }); + const capability = await request('host.registerCapability', { + id: COCOS_CONNECTION_CAPABILITY_ID, + description: 'Cocos Creator 连接探测、目标预检、ping、status 与注入', + }); + const panel = await request('host.registerPanel', { + ...COCOS_EDITOR_PANEL, + }); + const subscription = await request('host.events.subscribe', { + type: PROJECT_CHANGED_EVENT, + }); + activeProjectPath = subscription?.projectPath ?? null; + return { + command, + capability, + panel, + subscriptionId: subscription?.subscriptionId ?? null, + }; + } + + function dispose() { + disposed = true; + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(new Error('插件已停止')); + } + pending.clear(); + } + + return { + start, + handleMessage, + dispose, + get activeProjectPath() { + return activeProjectPath; + }, + }; +} + +export function startCocosEditorStdioPlugin({ + stdin = process.stdin, + stdout = process.stdout, + log = (message) => process.stderr.write(`${message}\n`), +} = {}) { + const plugin = createCocosEditorPlugin({ + send: (message) => stdout.write(`${JSON.stringify(message)}\n`), + log, + }); + const lines = createInterface({ input: stdin, crlfDelay: Infinity }); + lines.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + void plugin.handleMessage(trimmed).catch((error) => { + log(`cocos plugin 消息处理失败:${error.message}`); + }); + }); + void plugin.start().catch((error) => { + log(`cocos plugin 注册失败:${error.message}`); + }); + return plugin; +} + +const entryPoint = process.argv[1]; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + startCocosEditorStdioPlugin(); +} diff --git a/plugins/agc-cocos-editor/src/entry.test.mjs b/plugins/agc-cocos-editor/src/entry.test.mjs new file mode 100644 index 000000000..9efe0eb46 --- /dev/null +++ b/plugins/agc-cocos-editor/src/entry.test.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + buildEditorRpcRequest, + COCOS_EDITOR_OPERATIONS, + validateExecuteCode, +} from './cocos-editor-adapter.mjs'; +import { + COCOS_CONNECTION_CAPABILITY_ID, + COCOS_EDITOR_PANEL, + COCOS_EXECUTE_COMMAND_ID, + COCOS_PLUGIN_PROTOCOL_VERSION, + createCocosEditorPlugin, + PROJECT_CHANGED_EVENT, +} from './entry.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.join(here, '..'); + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function createHarness() { + const outbound = []; + const plugin = createCocosEditorPlugin({ + send: (message) => outbound.push(structuredClone(message)), + }); + const respond = (id, result) => + plugin.handleMessage({ jsonrpc: '2.0', id, result }); + return { plugin, outbound, respond }; +} + +async function startPlugin(harness, projectPath = 'C:\\demo') { + const started = harness.plugin.start(); + await tick(); + harness.respond(harness.outbound.at(-1).id, { registered: true }); + await tick(); + harness.respond(harness.outbound.at(-1).id, { registered: true }); + await tick(); + harness.respond(harness.outbound.at(-1).id, { registered: true }); + await tick(); + harness.respond(harness.outbound.at(-1).id, { + subscriptionId: 'sub-1', + projectPath, + }); + return started; +} + +test('runtime entry registers command, capability, panel and project event', async () => { + const harness = createHarness(); + const result = await startPlugin(harness); + const methods = harness.outbound.map((message) => message.method); + assert.deepEqual(methods, [ + 'host.registerCommand', + 'host.registerCapability', + 'host.registerPanel', + 'host.events.subscribe', + ]); + assert.equal(harness.outbound[0].params.id, COCOS_EXECUTE_COMMAND_ID); + assert.equal(harness.outbound[1].params.id, COCOS_CONNECTION_CAPABILITY_ID); + assert.deepEqual(harness.outbound[2].params, { ...COCOS_EDITOR_PANEL }); + assert.equal(harness.outbound[3].params.type, PROJECT_CHANGED_EVENT); + assert.equal(result.subscriptionId, 'sub-1'); + assert.equal(harness.plugin.activeProjectPath, 'C:\\demo'); +}); + +test('execute command routes through host.rpc with the active project', async () => { + const harness = createHarness(); + await startPlugin(harness); + const handling = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 77, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return Editor.Project.path;' }, + }); + await tick(); + const rpc = harness.outbound.at(-1); + assert.equal(rpc.method, 'host.rpc'); + assert.deepEqual(rpc.params, { + method: 'editor.execute', + params: { + projectPath: 'C:\\demo', + code: 'return Editor.Project.path;', + }, + }); + harness.respond(rpc.id, { ok: true, requestId: 'cocos-1' }); + await handling; + const reply = harness.outbound.at(-1); + assert.equal(reply.id, 77); + assert.equal(reply.result.status, 'completed'); +}); + +test('execute command fails closed without a project or with invalid code', async () => { + const harness = createHarness(); + await startPlugin(harness, null); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 1, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 1;' }, + }); + assert.match(harness.outbound.at(-1).error.message, /项目路径/); + + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 2, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: ' ', projectPath: 'C:\\demo' }, + }); + assert.match(harness.outbound.at(-1).error.message, /不能为空/); +}); + +test('connection capability dispatches adapter operations', async () => { + const harness = createHarness(); + await startPlugin(harness); + const handling = harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 5, + method: COCOS_CONNECTION_CAPABILITY_ID, + params: { operation: 'ping', processId: 42 }, + }); + await tick(); + const rpc = harness.outbound.at(-1); + assert.deepEqual(rpc.params, { + method: 'editor.ping', + params: { processId: 42, projectPath: 'C:\\demo' }, + }); + harness.respond(rpc.id, { ok: true }); + await handling; + assert.equal(harness.outbound.at(-1).result.ok, true); + + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + id: 6, + method: COCOS_CONNECTION_CAPABILITY_ID, + params: { operation: 'eval' }, + }); + assert.match(harness.outbound.at(-1).error.message, /不支持的能力操作/); +}); + +test('project.changed event updates the cached project path', async () => { + const harness = createHarness(); + await startPlugin(harness); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { + type: PROJECT_CHANGED_EVENT, + payload: { projectPath: 'D:\\other' }, + }, + }); + assert.equal(harness.plugin.activeProjectPath, 'D:\\other'); + await harness.plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { type: PROJECT_CHANGED_EVENT, payload: { projectPath: null } }, + }); + assert.equal(harness.plugin.activeProjectPath, null); +}); + +test('adapter request builder enforces per-operation parameters', () => { + assert.deepEqual( + buildEditorRpcRequest('status', { + processId: 7, + projectPath: 'C:\\demo', + }), + { + method: 'editor.status', + params: { processId: 7, projectPath: 'C:\\demo' }, + }, + ); + assert.throws( + () => + buildEditorRpcRequest('execute', { projectPath: 'C:\\demo', code: '' }), + /不能为空/, + ); + assert.deepEqual( + buildEditorRpcRequest('inject', { + processId: 1, + projectPath: 'C:\\demo', + payloadPath: 'C:\\payload\\cocos-editor-bridge.dll', + }).params.payloadPath, + 'C:\\payload\\cocos-editor-bridge.dll', + ); + assert.throws( + () => + buildEditorRpcRequest('ping', { + processId: 1, + projectPath: 'C:\\demo', + payloadPath: 'C:\\payload\\cocos-editor-bridge.dll', + }), + /payloadPath/, + ); + assert.throws(() => buildEditorRpcRequest('detect', {}), /缺少 projectPath/); + assert.throws(() => buildEditorRpcRequest('eval', {}), /不支持/); + assert.throws(() => validateExecuteCode('x'.repeat(128 * 1024 + 1)), /上限/); +}); + +test('manifest, native adapter and SDK agree on ids and protocol', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(pluginRoot, 'plugin.json'), 'utf8'), + ); + assert.equal( + manifest.$schema, + 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + ); + assert.equal(manifest.name, 'agc-cocos-editor'); + const runtime = manifest.extensions['world.genarrative.agc']; + assert.equal(runtime.apiVersion, 'v1'); + assert.equal(runtime.adapter, 'cocos-editor'); + assert.equal(runtime.entry, './src/entry.mjs'); + assert.ok(fs.existsSync(path.join(pluginRoot, runtime.entry))); + assert.deepEqual( + runtime.panels.map((panel) => panel.id), + [COCOS_EDITOR_PANEL.id], + ); + for (const panel of runtime.panels) { + assert.ok( + fs.existsSync(path.join(pluginRoot, panel.entry)), + `panel entry missing: ${panel.entry}`, + ); + } + assert.ok(runtime.permissions.includes('editor.rpc')); + assert.ok(runtime.permissions.includes('capability.register')); + + const nativeAdapter = fs.readFileSync( + path.join(pluginRoot, 'native/cocos-editor-bridge/src/adapter.rs'), + 'utf8', + ); + const nativeOperations = [ + ...nativeAdapter.matchAll(/\("editor\.([a-z]+)", "([a-z]+)"\)/gu), + ].map((match) => match[1]); + assert.deepEqual(nativeOperations, [...COCOS_EDITOR_OPERATIONS]); + assert.ok(nativeAdapter.includes(`"${runtime.adapter}"`)); + + const sdk = fs.readFileSync( + path.join(pluginRoot, '../../packages/agc-plugin-sdk/src/index.ts'), + 'utf8', + ); + assert.ok( + sdk.includes( + `AGC_PLUGIN_PROTOCOL_VERSION = '${COCOS_PLUGIN_PROTOCOL_VERSION}'`, + ), + ); + const host = fs.readFileSync( + path.join( + pluginRoot, + '../../apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs', + ), + 'utf8', + ); + assert.ok(host.includes(`"${COCOS_PLUGIN_PROTOCOL_VERSION}"`)); + assert.ok(host.includes(`"${runtime.apiVersion}"`)); +}); diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 6534b88bb..0f34205c4 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -14,6 +14,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([ 'packages/image-canvas-react', 'packages/agc-plugin-sdk', 'packages/shared', + 'plugins/agc-cocos-editor', 'tools/spine-json-export-validator', ]); @@ -27,6 +28,7 @@ const WORKSPACE_NAMES = Object.freeze({ 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', 'packages/shared': '@genarrative/shared', + 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'tools/spine-json-export-validator': '@genarrative/spine-json-export-validator', }); @@ -47,6 +49,7 @@ const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({ 'packages/image-canvas-react/package.json': [ '@genarrative/image-canvas-core', ], + 'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'], }); const DEPENDENCY_FIELDS = Object.freeze([ diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 678e7df88..5f995b859 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -22,6 +22,7 @@ const workspaceNames = { 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', 'packages/shared': '@genarrative/shared', + 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'tools/spine-json-export-validator': '@genarrative/spine-json-export-validator', }; @@ -35,6 +36,7 @@ const localDependencies = { }, 'apps/mobile-shell': { '@genarrative/shared': '0.1.0' }, 'packages/image-canvas-react': { '@genarrative/image-canvas-core': '0.1.0' }, + 'plugins/agc-cocos-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' }, }; afterEach(() => { diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 0dedf52f2..f5d8749e8 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -9,7 +9,7 @@ default-members = [ exclude = [ "crates/agent-runtime-core", "crates/agent-runtime-orchestration", - "crates/cocos-editor-bridge", + "crates/editor-adapter-api", "crates/platform-agent", ] members = [ diff --git a/server-rs/crates/editor-adapter-api/Cargo.toml b/server-rs/crates/editor-adapter-api/Cargo.toml new file mode 100644 index 000000000..ab3767a71 --- /dev/null +++ b/server-rs/crates/editor-adapter-api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "editor-adapter-api" +version = "0.1.0" +edition = "2021" +license = "UNLICENSED" +publish = false +description = "通用编辑器适配器契约,宿主与插件 native 模块共享" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/server-rs/crates/editor-adapter-api/src/lib.rs b/server-rs/crates/editor-adapter-api/src/lib.rs new file mode 100644 index 000000000..010e2f037 --- /dev/null +++ b/server-rs/crates/editor-adapter-api/src/lib.rs @@ -0,0 +1,62 @@ +//! 通用编辑器适配器契约。 +//! +//! AGC 插件宿主只负责插件生命周期、RPC、权限和审计;具体编辑器如何发现进程、 +//! 校验 PID / 项目 / 版本、建立连接并把通用请求翻译成编辑器消息,由插件包自带的 +//! native 适配器实现。本 crate 只定义双方共享的最小契约,不包含任何编辑器专属 +//! 进程名、注入逻辑或业务 API。 + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// rpc 请求的通用入参:`processId` / `projectPath` / `code` / `timeoutMs` / `payloadPath`。 +pub const ADAPTER_RPC_PARAMS_SCHEMA: &str = "agc.editor-adapter.params.v1"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorConnectionInfo { + pub adapter: String, + pub connected: bool, + pub pid: Option, + pub project_path: Option, + pub version: Option, +} + +impl EditorConnectionInfo { + pub fn disconnected(adapter: &str) -> Self { + Self { + adapter: adapter.to_string(), + connected: false, + pid: None, + project_path: None, + version: None, + } + } +} + +/// 宿主与插件 native 适配器之间的窄接口。 +/// +/// 宿主只把插件 manifest 声明的适配器名路由到这里;新增编辑器不会改变插件 +/// 生命周期、SDK 或权限协议。 +pub trait EditorAdapter: Send + Sync { + /// 适配器 id,必须与插件 manifest `extensions.world.genarrative.agc.adapter` 一致。 + fn id(&self) -> &'static str; + /// 按项目路径探测当前编辑器实例,不注入、不修改项目文件。 + fn detect(&self, project_path: &Path) -> Result; + /// 绑定目标 PID / 项目 / 版本,只登记连接意图,不产生副作用。 + fn connect( + &mut self, + pid: u32, + project_path: &Path, + version: &str, + ) -> Result; + /// 释放连接登记;不关闭编辑器、不回滚用户项目。 + fn disconnect(&mut self); + /// 把通用 RPC 方法名翻译成适配器方法名,未知方法失败关闭。 + fn translate_rpc(&self, method: &str, params: Value) -> Result; + /// 执行适配器方法:探测、预检、ping、status、execute、inject。 + fn rpc(&self, _method: &str, _params: Value) -> Result { + Err("编辑器原生连接尚未建立".to_string()) + } +}