4a46f89c9b
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。 - 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。 - Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。 - 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。 - DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。 验证: - 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。 - 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。 - 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。 - 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。 验证边界:发行安装包和远端 CI 尚未验收。 Reviewed-on: #338 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
351 lines
13 KiB
Rust
351 lines
13 KiB
Rust
#[path = "build_support/frontend_dist_guard.rs"]
|
||
mod frontend_dist_guard;
|
||
#[path = "build_support/runtime_prompt_bundle.rs"]
|
||
mod runtime_prompt_bundle;
|
||
|
||
#[cfg(windows)]
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::BTreeSet;
|
||
use std::env;
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
#[cfg(windows)]
|
||
use std::io::{BufReader, Read};
|
||
|
||
const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0";
|
||
|
||
#[cfg(windows)]
|
||
const BUNDLED_CODEX_FILES: [&str; 6] = [
|
||
"bin/codex.exe",
|
||
"bin/codex-code-mode-host.exe",
|
||
"codex-path/rg.exe",
|
||
"codex-resources/codex-command-runner.exe",
|
||
"codex-resources/codex-windows-sandbox-setup.exe",
|
||
"codex-package.json",
|
||
];
|
||
|
||
#[cfg(windows)]
|
||
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||
let file = fs::File::open(path)?;
|
||
let mut reader = BufReader::new(file);
|
||
let mut hasher = Sha256::new();
|
||
let mut buffer = [0_u8; 64 * 1024];
|
||
loop {
|
||
let read = reader.read(&mut buffer)?;
|
||
if read == 0 {
|
||
break;
|
||
}
|
||
hasher.update(&buffer[..read]);
|
||
}
|
||
Ok(format!("{:x}", hasher.finalize()))
|
||
}
|
||
|
||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||
#[cfg(windows)]
|
||
{
|
||
let app_root = manifest_dir
|
||
.parent()
|
||
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
|
||
let repo_root = app_root
|
||
.parent()
|
||
.and_then(|apps_dir| apps_dir.parent())
|
||
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
|
||
let source_candidates = [
|
||
app_root.join(
|
||
"node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
|
||
),
|
||
app_root.join(
|
||
"node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
|
||
),
|
||
repo_root.join(
|
||
"node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
|
||
),
|
||
repo_root.join(
|
||
"node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
|
||
),
|
||
];
|
||
let source = source_candidates
|
||
.iter()
|
||
.find(|path| {
|
||
BUNDLED_CODEX_FILES
|
||
.iter()
|
||
.all(|relative| path.join(relative).is_file())
|
||
})
|
||
.cloned()
|
||
.unwrap_or_else(|| {
|
||
panic!(
|
||
"内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
|
||
source_candidates
|
||
.iter()
|
||
.map(|path| path.display().to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(";")
|
||
)
|
||
});
|
||
let target_dir = manifest_dir.join("resources/codex/win-x64");
|
||
let notice = target_dir.join("NOTICE.md");
|
||
if !notice.is_file() {
|
||
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
|
||
}
|
||
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
|
||
let mut file_hashes = serde_json::Map::new();
|
||
for relative in BUNDLED_CODEX_FILES {
|
||
let source_path = source.join(relative);
|
||
let target_path = target_dir.join(relative);
|
||
if let Some(parent) = target_path.parent() {
|
||
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
|
||
}
|
||
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
|
||
let target_matches_source = target_path.is_file()
|
||
&& sha256_file(&target_path)
|
||
.map(|target_sha256| target_sha256 == source_sha256)
|
||
.unwrap_or(false);
|
||
if !target_matches_source {
|
||
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
|
||
}
|
||
file_hashes.insert(
|
||
relative.to_string(),
|
||
serde_json::Value::String(source_sha256),
|
||
);
|
||
}
|
||
let manifest = serde_json::json!({
|
||
"schemaVersion": "genarrative-codex-sidecar.v2",
|
||
"platform": "win32-x64",
|
||
"version": BUNDLED_CODEX_CLI_VERSION,
|
||
"files": file_hashes,
|
||
});
|
||
let manifest_path = target_dir.join("manifest.json");
|
||
let manifest_payload = format!(
|
||
"{}\n",
|
||
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
|
||
);
|
||
if fs::read_to_string(&manifest_path)
|
||
.map(|current| current != manifest_payload)
|
||
.unwrap_or(true)
|
||
{
|
||
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
|
||
}
|
||
for relative in BUNDLED_CODEX_FILES {
|
||
println!("cargo:rerun-if-changed={}", source.join(relative).display());
|
||
}
|
||
println!("cargo:rerun-if-changed={}", notice.display());
|
||
}
|
||
}
|
||
|
||
fn seed_task_group_id(
|
||
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
|
||
) -> &'static str {
|
||
use shared_contracts::game_creation_app::GameCreationAppAgentGroup;
|
||
match group {
|
||
GameCreationAppAgentGroup::Design => "design",
|
||
GameCreationAppAgentGroup::Art => "art",
|
||
GameCreationAppAgentGroup::Code => "code",
|
||
GameCreationAppAgentGroup::Balance => "balance",
|
||
GameCreationAppAgentGroup::Audio => "audio",
|
||
GameCreationAppAgentGroup::Publishing => "publishing",
|
||
}
|
||
}
|
||
|
||
fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBundle) {
|
||
let actual = compiled
|
||
.specialist_nodes
|
||
.iter()
|
||
.map(|node| {
|
||
(
|
||
node.task_id.clone(),
|
||
node.group_id.clone(),
|
||
node.role.clone(),
|
||
)
|
||
})
|
||
.collect::<BTreeSet<_>>();
|
||
let expected = shared_contracts::game_creation_app::new_game_creation_app_seed_tasks()
|
||
.into_iter()
|
||
.map(|task| {
|
||
(
|
||
task.id,
|
||
seed_task_group_id(&task.group).to_string(),
|
||
task.role,
|
||
)
|
||
})
|
||
.collect::<BTreeSet<_>>();
|
||
if actual != expected {
|
||
panic!(
|
||
"Prompt Bundle agentCatalog 与正式 seed DAG 的 taskId/group/role 不一致\nactual={actual:#?}\nexpected={expected:#?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn main() {
|
||
let manifest_dir = PathBuf::from(
|
||
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
|
||
);
|
||
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}"));
|
||
validate_seed_task_catalog(&compiled);
|
||
for dependency in &compiled.dependencies {
|
||
println!("cargo:rerun-if-changed={}", dependency.display());
|
||
}
|
||
let output_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be available"))
|
||
.join("agent_runtime_prompt_bundle.rs");
|
||
fs::write(&output_path, compiled.rust_source)
|
||
.unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}"));
|
||
if !tauri_build::is_dev() {
|
||
let frontend_dist = manifest_dir
|
||
.parent()
|
||
.expect("AI 游戏创作 Tauri manifest 必须位于应用目录下")
|
||
.join("dist");
|
||
println!("cargo:rerun-if-changed={}", frontend_dist.display());
|
||
frontend_dist_guard::validate_frontend_dist(&frontend_dist)
|
||
.unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}"));
|
||
}
|
||
tauri_build::build()
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
|
||
if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
|
||
return;
|
||
}
|
||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||
let profile_dir = out_dir
|
||
.ancestors()
|
||
.find(|path| path.file_name().is_some_and(|name| name == "build"))
|
||
.and_then(|build_dir| build_dir.parent())
|
||
.expect("AGC Cargo profile directory not found");
|
||
let candidates = [
|
||
profile_dir.join("deps/cocos_editor_bridge.dll"),
|
||
profile_dir.join("cocos_editor_bridge.dll"),
|
||
];
|
||
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(";")
|
||
)
|
||
});
|
||
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 stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
|
||
let Ok(bytes) = std::fs::read(source) else {
|
||
return;
|
||
};
|
||
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
|
||
return;
|
||
}
|
||
if let Some(parent) = destination.parent() {
|
||
std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
|
||
}
|
||
std::fs::write(destination, bytes).expect("复制插件资源失败");
|
||
}
|
||
|
||
#[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;
|
||
}
|
||
stage_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) {}
|