新增 AGC 内置插件模式与插件工作区
- 新增 plugins/ 插件工作区及 agc-cocos-editor 插件包,Cocos 直连模块从宿主源码树迁入插件包 - 新增 server-rs/crates/editor-adapter-api 通用编辑器适配器契约,宿主只保留适配器注册与 RPC 路由 - 新增内置插件登记表与可用开关,状态持久化在 AppData extensions/builtin-plugins.json,状态文件损坏时失败关闭 - 禁用内置插件时先停止进程并拒绝启动,同时从 Runtime 工具目录、工具策略快照、原生函数目录与 DirectProject 工具面移除对应工具 - 插件宿主新增 plugins/ 工作区扫描,内置插件优先于同名 AppData 导入插件,前端只提供可用开关而不提供重命名和卸载入口 - 编辑器操作统一经 host.rpc 路由到插件包自带的 native 适配器,并删除 Cocos 专属 Tauri 命令 - 修正 Windows 打包与开发态 payload 路径,随包映射 plugins 工作区资源 - 同步插件、Cocos 桥接、实施计划文档与 decision log
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::<Vec<_>>().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::<Vec<_>>()
|
||||
.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) {}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
let serialized = specs.to_string();
|
||||
|
||||
+6
-4
@@ -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> {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -251,8 +251,17 @@ fn build_agent_runtime_native_capability_registry() -> Result<CapabilityRegistry
|
||||
|
||||
fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegistry<String>, String>
|
||||
{
|
||||
static REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
|
||||
REGISTRY
|
||||
// 内置插件开关会改变工具目录,因此按“可用 / 不可用”各缓存一份:切换后立即
|
||||
// 生效,又不需要每次调用都重建 registry。
|
||||
static ENABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, String>> = OnceLock::new();
|
||||
static DISABLED_REGISTRY: OnceLock<Result<CapabilityRegistry<String>, 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)
|
||||
|
||||
@@ -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<BuiltinPlugin> {
|
||||
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<PathBuf>,
|
||||
enabled: BTreeMap<String, bool>,
|
||||
/// 开关文件不可读或格式不受支持时,内置插件全部按不可用处理。
|
||||
fail_closed: bool,
|
||||
}
|
||||
|
||||
static STATE: OnceLock<Mutex<BuiltinPluginState>> = OnceLock::new();
|
||||
|
||||
fn state() -> &'static Mutex<BuiltinPluginState> {
|
||||
STATE.get_or_init(|| Mutex::new(BuiltinPluginState::default()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BuiltinPluginStateFile {
|
||||
#[serde(default)]
|
||||
schema_version: Option<String>,
|
||||
#[serde(default)]
|
||||
enabled: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
/// 读取 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::<BuiltinPluginStateFile>(&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<PathBuf>) {
|
||||
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<bool> {
|
||||
builtin_plugin(id).map(|_| is_enabled(id))
|
||||
}
|
||||
|
||||
pub(crate) fn set_enabled(id: &str, enabled: bool) -> Result<bool, String> {
|
||||
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<Mutex<()>> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CocosEditorProcess, String> {
|
||||
#[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<CocosEditorCommandResponse, String> {
|
||||
#[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<CocosEditorCommandResponse, String> {
|
||||
#[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<CocosEditorCommandResponse, String> {
|
||||
#[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<CocosEditorInjectionResult, String> {
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
pub(crate) project_path: Option<String>,
|
||||
pub(crate) version: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<EditorConnectionInfo, String>;
|
||||
fn connect(
|
||||
&mut self,
|
||||
pid: u32,
|
||||
project_path: &Path,
|
||||
version: &str,
|
||||
) -> Result<EditorConnectionInfo, String>;
|
||||
fn disconnect(&mut self);
|
||||
fn translate_rpc(&self, method: &str, params: Value) -> Result<Value, String>;
|
||||
fn rpc(&self, _method: &str, _params: Value) -> Result<Value, String> {
|
||||
Err("编辑器原生连接尚未建立".to_string())
|
||||
}
|
||||
}
|
||||
pub(crate) use editor_adapter_api::{EditorAdapter, EditorConnectionInfo};
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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
|
||||
}
|
||||
@@ -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::<PluginHost>().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::<PluginHost>().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::<PluginHost>().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 {
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<PluginSummary>,
|
||||
pub(crate) client_extension: Option<crate::client_extensions::ClientExtensionItem>,
|
||||
@@ -206,6 +209,7 @@ struct PluginRecord {
|
||||
#[derive(Default)]
|
||||
struct PluginHostState {
|
||||
root: Option<PathBuf>,
|
||||
workspace: Option<PathBuf>,
|
||||
plugins: BTreeMap<String, PluginRecord>,
|
||||
active_project: ProjectContext,
|
||||
editors: EditorRegistry,
|
||||
@@ -461,6 +465,84 @@ fn plugin_root(config_dir: &Path) -> Result<PathBuf, String> {
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`,
|
||||
/// 开发构建再回退仓库里的 `plugins/` 工作区。
|
||||
pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
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<bool>,
|
||||
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<Vec<ScannedPluginSource>, 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::<Vec<_>>();
|
||||
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<Vec<PluginSummary>, 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<Vec<PluginSummary>, 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<EditorConnectionInfo, String> {
|
||||
Err("stub adapter 不探测进程".to_string())
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&mut self,
|
||||
_pid: u32,
|
||||
_project_path: &Path,
|
||||
_version: &str,
|
||||
) -> Result<EditorConnectionInfo, String> {
|
||||
Err("stub adapter 不建立连接".to_string())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) {}
|
||||
|
||||
fn translate_rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn rpc(&self, method: &str, params: Value) -> Result<Value, String> {
|
||||
// 与 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<PluginSummary>| {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
) : null}
|
||||
{clientExtensionsLoadState === 'loading' ? (
|
||||
<div className="runtime-settings-empty-state" role="status">
|
||||
<strong>正在加载扩展</strong>
|
||||
<span>正在读取已导入的 Skill 和 MCP。</span>
|
||||
</div>
|
||||
) : clientExtensionsLoadState === 'error' ? (
|
||||
<div className="runtime-settings-empty-state" role="alert">
|
||||
<strong>扩展列表加载失败</strong>
|
||||
<span>
|
||||
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
|
||||
</span>
|
||||
</div>
|
||||
) : clientExtensions.length > 0 ? (
|
||||
{agcPlugins.some((plugin) => plugin.builtin) ? (
|
||||
<div className="runtime-settings-extension-list">
|
||||
{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) => (
|
||||
<article
|
||||
className="runtime-settings-extension-item"
|
||||
key={item.id}
|
||||
key={plugin.id}
|
||||
>
|
||||
<div className="runtime-settings-extension-main">
|
||||
{editing ? (
|
||||
<input
|
||||
aria-label={`${item.name} 新名称`}
|
||||
autoFocus
|
||||
value={editingExtensionName}
|
||||
onChange={(event) =>
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<strong title={item.name}>{item.name}</strong>
|
||||
)}
|
||||
<span>
|
||||
{typeLabel} · 来自 {item.sourceName}
|
||||
</span>
|
||||
{item.lastError ? (
|
||||
<small title={item.lastError}>
|
||||
{item.lastError}
|
||||
<strong title={plugin.name}>{plugin.name}</strong>
|
||||
<span>内置 Plugin · 不可卸载</span>
|
||||
{plugin.lastError ? (
|
||||
<small title={plugin.lastError}>
|
||||
{plugin.lastError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="runtime-settings-extension-actions">
|
||||
<span>
|
||||
{plugin?.status === 'running'
|
||||
? '运行中'
|
||||
: statusLabel}
|
||||
{!plugin.enabled
|
||||
? '已禁用'
|
||||
: plugin.status === 'running'
|
||||
? '运行中'
|
||||
: plugin.status === 'invalid'
|
||||
? '当前不可用'
|
||||
: '已启用'}
|
||||
</span>
|
||||
{plugin?.enabled && plugin.hasRuntime ? (
|
||||
{plugin.enabled && plugin.hasRuntime ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
agcPluginsBusy ||
|
||||
clientExtensionsBusy ||
|
||||
plugin.status === 'invalid'
|
||||
}
|
||||
onClick={() => void toggleAgcPlugin(plugin)}
|
||||
@@ -1047,7 +1016,7 @@ export function RuntimeConfigDialog({
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{plugin?.status === 'running'
|
||||
{plugin.status === 'running'
|
||||
? plugin.panels.map((panel) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1063,66 +1032,226 @@ export function RuntimeConfigDialog({
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void saveClientExtensionName(item)
|
||||
}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={cancelRenameClientExtension}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重命名 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
beginRenameClientExtension(item)
|
||||
}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{item.extensionType === 'unknown' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.enabled}
|
||||
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void setClientExtensionEnabled(
|
||||
item,
|
||||
!item.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() => void removeClientExtension(item)}
|
||||
role="switch"
|
||||
aria-checked={plugin.enabled}
|
||||
aria-label={`${plugin.name} ${
|
||||
plugin.enabled ? '禁用' : '启用'
|
||||
}`}
|
||||
disabled={agcPluginsBusy}
|
||||
onClick={() =>
|
||||
void setBuiltinPluginEnabled(
|
||||
plugin,
|
||||
!plugin.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
{plugin.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{clientExtensionsLoadState === 'loading' ? (
|
||||
<div className="runtime-settings-empty-state" role="status">
|
||||
<strong>正在加载扩展</strong>
|
||||
<span>正在读取已导入的 Skill 和 MCP。</span>
|
||||
</div>
|
||||
) : clientExtensionsLoadState === 'error' ? (
|
||||
<div className="runtime-settings-empty-state" role="alert">
|
||||
<strong>扩展列表加载失败</strong>
|
||||
<span>
|
||||
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
|
||||
</span>
|
||||
</div>
|
||||
) : clientExtensions.length > 0 ? (
|
||||
<div className="runtime-settings-extension-list">
|
||||
{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 (
|
||||
<article
|
||||
className="runtime-settings-extension-item"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="runtime-settings-extension-main">
|
||||
{editing ? (
|
||||
<input
|
||||
aria-label={`${item.name} 新名称`}
|
||||
autoFocus
|
||||
value={editingExtensionName}
|
||||
onChange={(event) =>
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<strong title={item.name}>{item.name}</strong>
|
||||
)}
|
||||
<span>
|
||||
{typeLabel} · 来自 {item.sourceName}
|
||||
</span>
|
||||
{item.lastError ? (
|
||||
<small title={item.lastError}>
|
||||
{item.lastError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="runtime-settings-extension-actions">
|
||||
<span>
|
||||
{plugin?.status === 'running'
|
||||
? '运行中'
|
||||
: statusLabel}
|
||||
</span>
|
||||
{plugin?.enabled && plugin.hasRuntime ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
agcPluginsBusy ||
|
||||
clientExtensionsBusy ||
|
||||
plugin.status === 'invalid'
|
||||
}
|
||||
onClick={() =>
|
||||
void toggleAgcPlugin(plugin)
|
||||
}
|
||||
>
|
||||
{plugin.status === 'running'
|
||||
? '停止'
|
||||
: '启动'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
agcPluginsBusy ||
|
||||
plugin.status !== 'running'
|
||||
}
|
||||
onClick={() => void reloadPlugin(plugin)}
|
||||
>
|
||||
重载
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{plugin?.status === 'running'
|
||||
? plugin.panels.map((panel) => (
|
||||
<button
|
||||
type="button"
|
||||
key={panel.id}
|
||||
onClick={() =>
|
||||
setMountedPluginPanel({
|
||||
pluginId: plugin.id,
|
||||
panel,
|
||||
})
|
||||
}
|
||||
>
|
||||
{panel.title}
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void saveClientExtensionName(item)
|
||||
}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={cancelRenameClientExtension}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`重命名 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
beginRenameClientExtension(item)
|
||||
}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{item.extensionType === 'unknown' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.enabled}
|
||||
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void setClientExtensionEnabled(
|
||||
item,
|
||||
!item.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${item.name}`}
|
||||
disabled={clientExtensionsBusy}
|
||||
onClick={() =>
|
||||
void removeClientExtension(item)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="runtime-settings-empty-state">
|
||||
|
||||
@@ -45,6 +45,14 @@ export async function reloadAgcPlugin(id: string) {
|
||||
}) as Promise<AgcPluginSummary>;
|
||||
}
|
||||
|
||||
/** 内置插件的可用开关;禁用后不能启动,对应 Agent 工具也不再出现。 */
|
||||
export async function setAgcPluginEnabled(id: string, enabled: boolean) {
|
||||
return invokeOrThrow()('set_agc_plugin_enabled', {
|
||||
id,
|
||||
enabled,
|
||||
}) as Promise<AgcPluginSummary[]>;
|
||||
}
|
||||
|
||||
export async function callAgcPlugin<T = unknown>(
|
||||
id: string,
|
||||
method: string,
|
||||
|
||||
@@ -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`、随包 `<resource_dir>/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。
|
||||
|
||||
@@ -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`(打包后位于 `<resource_dir>/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`、随包 `<resource_dir>/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 只生成三种操作:
|
||||
|
||||
|
||||
@@ -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`、随包资源目录 `<resource_dir>/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 方案文档单独执行,不用未验证的连接状态替代。
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 技术选择
|
||||
|
||||
|
||||
Generated
+18
@@ -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"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user