From edf6e19b3894a7c2208fa323a9930223b054d138 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 31 Aug 2026 11:37:04 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20DirectProject=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=AF=BC=E5=85=A5=E9=98=B6=E6=AE=B5=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增客户端扩展来源存储和 Skill/MCP 结构发现。 接入独立扩展列表、默认启用、重命名、删除和导入入口。 补充阶段一定向测试并更新实施方案状态。 --- .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/client_extensions.rs | 866 ++++++++++++++++++ .../src-tauri/src/main.rs | 9 + apps/ai-game-creator-shell/src/app/types.ts | 21 + .../runtime-config/RuntimeConfigDialog.tsx | 341 +++++++ apps/ai-game-creator-shell/src/styles.css | 148 +++ ...roject客户端Skill与MCP扩展导入方案-2026-08-31.md | 2 + 8 files changed, 1389 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..da4ec89d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1741,6 +1741,7 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "tokio", + "toml 0.8.2", "ts-rs", "ttf-parser", "typed_floats", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..e975d6a51 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-opener = "2" tempfile = "3" +toml = "0.8" ttf-parser = "0.25.1" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } url = "2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs new file mode 100644 index 000000000..779ba5780 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -0,0 +1,866 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; +use zip::ZipArchive; + +const CLIENT_EXTENSIONS_DIR_NAME: &str = "extensions"; +const CLIENT_EXTENSIONS_INDEX_FILE_NAME: &str = "index.json"; +const CLIENT_EXTENSIONS_SCHEMA_VERSION: &str = "direct-project-client-extensions.v1"; +const CLIENT_EXTENSIONS_SOURCES_DIR_NAME: &str = "sources"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientExtensionItem { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) original_name: String, + pub(crate) extension_type: String, + pub(crate) source_name: String, + pub(crate) source_relative_path: String, + pub(crate) enabled: bool, + pub(crate) status: String, + pub(crate) last_error: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientExtensionImportResult { + pub(crate) imported: Vec, + pub(crate) source_name: String, + pub(crate) renamed: bool, + pub(crate) duplicate: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientExtensionIndex { + schema_version: String, + sources: Vec, + items: Vec, +} + +impl Default for ClientExtensionIndex { + fn default() -> Self { + Self { + schema_version: CLIENT_EXTENSIONS_SCHEMA_VERSION.to_string(), + sources: Vec::new(), + items: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedExtensionSource { + id: String, + original_name: String, + storage_path: String, + fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredExtensionItem { + id: String, + source_id: String, + extension_type: String, + name: String, + original_name: String, + source_relative_path: String, + enabled: bool, + fingerprint: String, + last_error: Option, + #[serde(default)] + mcp_config: Option, +} + +struct ImportedCandidate { + extension_type: String, + original_name: String, + source_relative_path: String, + fingerprint: String, + mcp_config: Option, +} + +fn extensions_root() -> Result { + let config_path = crate::writable_game_creator_config_path()?; + let config_dir = config_path + .parent() + .ok_or_else(|| "客户端配置文件缺少父目录".to_string())?; + let root = config_dir.join(CLIENT_EXTENSIONS_DIR_NAME); + crate::ensure_game_creator_private_directory_tree(&root, "客户端扩展目录") + .map_err(|error| format!("准备客户端扩展目录失败:{error}"))?; + crate::ensure_game_creator_private_directory_tree( + &root.join(CLIENT_EXTENSIONS_SOURCES_DIR_NAME), + "客户端扩展来源目录", + ) + .map_err(|error| format!("准备客户端扩展来源目录失败:{error}"))?; + Ok(root) +} + +fn index_path(root: &Path) -> PathBuf { + root.join(CLIENT_EXTENSIONS_INDEX_FILE_NAME) +} + +fn read_index(root: &Path) -> Result { + let path = index_path(root); + if !path.exists() { + return Ok(ClientExtensionIndex::default()); + } + let content = + crate::read_game_creator_private_file_to_string(&path, "客户端扩展索引", 4 * 1024 * 1024)?; + let index = serde_json::from_str::(&content) + .map_err(|error| format!("解析客户端扩展索引失败:{error}"))?; + if index.schema_version != CLIENT_EXTENSIONS_SCHEMA_VERSION { + return Err(format!( + "客户端扩展索引版本不支持:{}", + index.schema_version + )); + } + Ok(index) +} + +fn write_index(root: &Path, index: &ClientExtensionIndex) -> Result<(), String> { + let content = serde_json::to_string_pretty(index) + .map_err(|error| format!("序列化客户端扩展索引失败:{error}"))?; + crate::write_game_creator_config_atomically(&index_path(root), &format!("{content}\n")) +} + +fn new_id(prefix: &str) -> String { + format!("{}-{}", prefix, uuid::Uuid::new_v4().simple()) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn hash_file(path: &Path) -> Result { + let bytes = + fs::read(path).map_err(|error| format!("读取扩展文件失败:{}: {error}", path.display()))?; + Ok(sha256_bytes(&bytes)) +} + +fn normalize_relative_path(path: &Path) -> Result { + let mut components = Vec::new(); + for component in path.components() { + match component { + std::path::Component::Normal(value) => { + components.push(value.to_string_lossy().into_owned()) + } + _ => return Err(format!("扩展来源包含无效相对路径:{}", path.display())), + } + } + if components.is_empty() { + return Err("扩展来源相对路径不能为空".to_string()); + } + Ok(components.join("/")) +} + +fn sorted_directory_files(root: &Path) -> Result, String> { + let mut entries = Vec::new(); + fn visit( + root: &Path, + current: &Path, + entries: &mut Vec<(String, PathBuf)>, + ) -> Result<(), String> { + let mut children = fs::read_dir(current) + .map_err(|error| format!("读取扩展目录失败:{}: {error}", current.display()))? + .collect::, _>>() + .map_err(|error| format!("读取扩展目录项失败:{}: {error}", current.display()))?; + children.sort_by_key(|entry| entry.file_name()); + for entry in children { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取扩展目录项失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!("扩展目录不能包含符号链接:{}", path.display())); + } + if metadata.is_dir() { + visit(root, &path, entries)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| format!("解析扩展目录相对路径失败:{}", path.display()))?; + entries.push((normalize_relative_path(relative)?, path)); + } + } + Ok(()) + } + visit(root, root, &mut entries)?; + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(entries) +} + +fn fingerprint_directory(root: &Path) -> Result { + let mut hasher = Sha256::new(); + for (relative, path) in sorted_directory_files(root)? { + let bytes = fs::read(&path) + .map_err(|error| format!("读取扩展文件失败:{}: {error}", path.display()))?; + hasher.update((relative.len() as u64).to_le_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn copy_file(source: &Path, destination: &Path) -> Result<(), String> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建扩展存储目录失败:{}: {error}", parent.display()))?; + } + fs::copy(source, destination).map_err(|error| { + format!( + "保存扩展文件失败:{} -> {}: {error}", + source.display(), + destination.display() + ) + })?; + Ok(()) +} + +fn copy_directory_contents(source: &Path, destination: &Path) -> Result<(), String> { + for (relative, path) in sorted_directory_files(source)? { + copy_file( + &path, + &destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR)), + )?; + } + Ok(()) +} + +fn extract_zip(source: &Path, destination: &Path) -> Result<(), String> { + let file = fs::File::open(source) + .map_err(|error| format!("打开扩展 zip 失败:{}: {error}", source.display()))?; + let mut archive = ZipArchive::new(file) + .map_err(|error| format!("读取扩展 zip 失败:{}: {error}", source.display()))?; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取扩展 zip 条目失败:{error}"))?; + let enclosed = entry + .enclosed_name() + .ok_or_else(|| "扩展 zip 包含越界路径".to_string())? + .to_path_buf(); + let output = destination.join(&enclosed); + if entry.is_dir() { + fs::create_dir_all(&output) + .map_err(|error| format!("创建扩展 zip 目录失败:{}: {error}", output.display()))?; + continue; + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建扩展 zip 父目录失败:{}: {error}", parent.display()) + })?; + } + let mut output_file = fs::File::create(&output) + .map_err(|error| format!("写入扩展 zip 文件失败:{}: {error}", output.display()))?; + std::io::copy(&mut entry, &mut output_file) + .map_err(|error| format!("解压扩展 zip 文件失败:{}: {error}", output.display()))?; + } + Ok(()) +} + +fn parse_skill_name(path: &Path) -> String { + let fallback = path + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .filter(|value| !value.starts_with(".staging-")) + .unwrap_or("skill") + .to_string(); + let Ok(content) = fs::read_to_string(path) else { + return fallback; + }; + let mut lines = content.lines(); + if lines.next().map(str::trim) != Some("---") { + return fallback; + } + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + break; + } + if let Some(value) = trimmed.strip_prefix("name:") { + let value = value.trim().trim_matches(['"', '\'']); + if !value.is_empty() { + return value.to_string(); + } + } + } + fallback +} + +fn native_name(value: &str) -> String { + let mut result = String::new(); + let mut last_was_separator = false; + for character in value.trim().chars() { + if character.is_ascii_alphanumeric() || character == '_' || character == '-' { + result.push(character); + last_was_separator = false; + } else if !last_was_separator { + result.push('-'); + last_was_separator = true; + } + } + let result = result.trim_matches('-').to_string(); + if result.is_empty() { + "extension".to_string() + } else { + result + } +} + +fn allocate_name(original_name: &str, existing: &mut BTreeSet) -> (String, bool) { + let base = native_name(original_name); + if existing.insert(base.clone()) { + return (base, false); + } + let mut suffix = 2_u32; + loop { + let candidate = format!("{base}-{suffix}"); + if existing.insert(candidate.clone()) { + return (candidate, true); + } + suffix += 1; + } +} + +fn source_name(path: &Path) -> String { + path.file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("extension") + .to_string() +} + +fn is_skill_file(path: &Path) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("SKILL.md")) +} + +fn parse_mcp_config_file(path: &Path) -> Result, String> { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if file_name.eq_ignore_ascii_case(".mcp.json") { + let Ok(content) = fs::read_to_string(path) else { + return Ok(Vec::new()); + }; + let Ok(value) = serde_json::from_str::(&content) else { + return Ok(Vec::new()); + }; + let Some(servers) = value + .get("mcpServers") + .and_then(serde_json::Value::as_object) + else { + return Ok(Vec::new()); + }; + return Ok(servers + .iter() + .map(|(name, config)| (name.clone(), config.clone())) + .collect()); + } + if !file_name.to_ascii_lowercase().ends_with(".toml") { + return Ok(Vec::new()); + } + let Ok(content) = fs::read_to_string(path) else { + return Ok(Vec::new()); + }; + let Ok(value) = content.parse::() else { + return Ok(Vec::new()); + }; + let Some(servers) = value.get("mcp_servers").and_then(toml::Value::as_table) else { + return Ok(Vec::new()); + }; + servers + .iter() + .map(|(name, config)| { + serde_json::to_value(config) + .map(|value| (name.clone(), value)) + .map_err(|error| format!("转换 MCP TOML 配置失败:{}: {error}", path.display())) + }) + .collect() +} + +fn discover_candidates(payload: &Path) -> Result, String> { + let files = sorted_directory_files(payload)?; + let mut candidates = Vec::new(); + for (relative, path) in files { + if is_skill_file(&path) { + candidates.push(ImportedCandidate { + extension_type: "skill".to_string(), + original_name: parse_skill_name(&path), + source_relative_path: relative, + fingerprint: hash_file(&path)?, + mcp_config: None, + }); + continue; + } + for (name, config) in parse_mcp_config_file(&path)? { + let config_bytes = serde_json::to_vec(&config) + .map_err(|error| format!("序列化 MCP 配置失败:{error}"))?; + candidates.push(ImportedCandidate { + extension_type: "mcp".to_string(), + original_name: name, + source_relative_path: relative.clone(), + fingerprint: sha256_bytes(&config_bytes), + mcp_config: Some(config), + }); + } + } + Ok(candidates) +} + +fn prepare_payload(source: &Path, staging: &Path) -> Result { + let metadata = fs::metadata(source) + .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; + fs::create_dir_all(staging) + .map_err(|error| format!("创建扩展暂存目录失败:{}: {error}", staging.display()))?; + if metadata.is_dir() { + copy_directory_contents(source, staging)?; + return fingerprint_directory(source); + } + if !metadata.is_file() { + return Err("扩展来源必须是普通文件或目录".to_string()); + } + let fingerprint = hash_file(source)?; + let file_name = source_name(source); + if file_name.to_ascii_lowercase().ends_with(".zip") { + match extract_zip(source, staging) { + Ok(()) => {} + Err(error) if error.contains("越界") => return Err(error), + Err(_) => { + fs::remove_dir_all(staging).map_err(|error| { + format!( + "清理无效扩展 zip 暂存目录失败:{}: {error}", + staging.display() + ) + })?; + fs::create_dir_all(staging).map_err(|error| { + format!("重建扩展 zip 暂存目录失败:{}: {error}", staging.display()) + })?; + copy_file(source, &staging.join(&file_name))?; + } + } + } else { + copy_file(source, &staging.join(&file_name))?; + } + Ok(fingerprint) +} + +fn stored_item_view( + index: &ClientExtensionIndex, + item: &StoredExtensionItem, +) -> ClientExtensionItem { + let source_name = index + .sources + .iter() + .find(|source| source.id == item.source_id) + .map(|source| source.original_name.clone()) + .unwrap_or_else(|| "未知来源".to_string()); + let status = if item.extension_type == "unknown" { + "unknown" + } else if item.last_error.is_some() { + "startup-failed" + } else if item.enabled { + "enabled" + } else { + "disabled" + }; + ClientExtensionItem { + id: item.id.clone(), + name: item.name.clone(), + original_name: item.original_name.clone(), + extension_type: item.extension_type.clone(), + source_name, + source_relative_path: item.source_relative_path.clone(), + enabled: item.enabled, + status: status.to_string(), + last_error: item.last_error.clone(), + } +} + +#[tauri::command] +pub(crate) async fn pick_client_extension_file( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 文件"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_file(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展文件选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展文件失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, false); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) async fn pick_client_extension_directory( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 目录"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_folder(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展目录选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) fn list_client_extensions() -> Result, String> { + let root = extensions_root()?; + let index = read_index(&root)?; + Ok(index + .items + .iter() + .map(|item| stored_item_view(&index, item)) + .collect()) +} + +#[tauri::command] +pub(crate) fn import_client_extension( + source_path: String, +) -> Result { + let source = PathBuf::from(source_path.trim()); + if source.as_os_str().is_empty() { + return Err("扩展来源不能为空".to_string()); + } + if !source.is_absolute() { + return Err("扩展来源必须是绝对路径".to_string()); + } + let metadata = fs::metadata(&source) + .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; + if !metadata.is_file() && !metadata.is_dir() { + return Err("扩展来源必须是普通文件或目录".to_string()); + } + crate::prepare_game_creator_user_selected_path_for_read( + &source, + metadata.is_dir(), + "客户端扩展来源", + )?; + + let root = extensions_root()?; + let mut index = read_index(&root)?; + let source_id = new_id("source"); + let source_display_name = source_name(&source); + let source_storage_relative = format!("{}/{}", CLIENT_EXTENSIONS_SOURCES_DIR_NAME, source_id); + let source_storage = root.join(&source_storage_relative); + let staging = root.join(format!(".staging-{}", uuid::Uuid::new_v4().simple())); + let result = (|| { + let source_fingerprint = prepare_payload(&source, &staging)?; + let mut candidates = discover_candidates(&staging)?; + if candidates.is_empty() { + candidates.push(ImportedCandidate { + extension_type: "unknown".to_string(), + original_name: source_display_name.clone(), + source_relative_path: source_display_name.clone(), + fingerprint: if metadata.is_file() { + source_fingerprint.clone() + } else { + fingerprint_directory(&staging)? + }, + mcp_config: None, + }); + } + fs::rename(&staging, &source_storage).map_err(|error| { + format!( + "安装扩展来源失败:{} -> {}: {error}", + staging.display(), + source_storage.display() + ) + })?; + let source_record = ImportedExtensionSource { + id: source_id.clone(), + original_name: source_display_name.clone(), + storage_path: source_storage_relative, + fingerprint: source_fingerprint, + }; + let mut used_names: BTreeMap> = BTreeMap::new(); + for item in &index.items { + used_names + .entry(item.extension_type.clone()) + .or_default() + .insert(item.name.clone()); + } + let mut imported = Vec::new(); + let mut renamed = false; + let mut duplicate = false; + for candidate in candidates { + let extension_type = candidate.extension_type; + let enabled = extension_type != "unknown"; + let duplicate_name = index + .items + .iter() + .find(|item| { + item.extension_type == extension_type + && item.fingerprint == candidate.fingerprint + }) + .map(|item| item.name.clone()); + duplicate |= duplicate_name.is_some(); + let names = used_names.entry(extension_type.clone()).or_default(); + let (name, was_renamed) = allocate_name( + duplicate_name + .as_deref() + .unwrap_or(&candidate.original_name), + names, + ); + renamed |= was_renamed; + let stored = StoredExtensionItem { + id: new_id("extension"), + source_id: source_id.clone(), + extension_type, + name, + original_name: candidate.original_name, + source_relative_path: candidate.source_relative_path, + enabled, + fingerprint: candidate.fingerprint, + last_error: None, + mcp_config: candidate.mcp_config, + }; + index.items.push(stored); + imported.push(stored_item_view( + &ClientExtensionIndex { + schema_version: index.schema_version.clone(), + sources: index + .sources + .iter() + .cloned() + .chain(std::iter::once(source_record.clone())) + .collect(), + items: index.items.clone(), + }, + index.items.last().expect("stored extension item"), + )); + } + index.sources.push(source_record); + write_index(&root, &index)?; + Ok(ClientExtensionImportResult { + imported, + source_name: source_display_name, + renamed, + duplicate, + }) + })(); + let _ = fs::remove_dir_all(&staging); + result +} + +#[tauri::command] +pub(crate) fn set_client_extension_enabled( + id: String, + enabled: bool, +) -> Result { + let root = extensions_root()?; + let mut index = read_index(&root)?; + let item = index + .items + .iter_mut() + .find(|item| item.id == id.trim()) + .ok_or_else(|| "未找到客户端扩展".to_string())?; + if item.extension_type == "unknown" && enabled { + return Err("未识别扩展不能启用".to_string()); + } + item.enabled = enabled; + item.last_error = None; + let view_item = item.clone(); + write_index(&root, &index)?; + Ok(stored_item_view(&index, &view_item)) +} + +#[tauri::command] +pub(crate) fn rename_client_extension( + id: String, + name: String, +) -> Result { + let root = extensions_root()?; + let mut index = read_index(&root)?; + let item_index = index + .items + .iter() + .position(|item| item.id == id.trim()) + .ok_or_else(|| "未找到客户端扩展".to_string())?; + let extension_type = index.items[item_index].extension_type.clone(); + let requested = name.trim(); + if requested.is_empty() { + return Err("扩展名称不能为空".to_string()); + } + let normalized = native_name(requested); + let conflict = index.items.iter().enumerate().any(|(index, item)| { + index != item_index && item.extension_type == extension_type && item.name == normalized + }); + let final_name = if conflict { + let mut names = index + .items + .iter() + .filter(|item| item.extension_type == extension_type) + .map(|item| item.name.clone()) + .collect::>(); + allocate_name(&normalized, &mut names).0 + } else { + normalized + }; + index.items[item_index].name = final_name; + let view_item = index.items[item_index].clone(); + write_index(&root, &index)?; + Ok(stored_item_view(&index, &view_item)) +} + +#[tauri::command] +pub(crate) fn remove_client_extension(id: String) -> Result<(), String> { + let root = extensions_root()?; + let mut index = read_index(&root)?; + let item_index = index + .items + .iter() + .position(|item| item.id == id.trim()) + .ok_or_else(|| "未找到客户端扩展".to_string())?; + index.items.remove(item_index); + write_index(&root, &index) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn native_names_use_one_runtime_and_ui_identifier() { + let mut names = BTreeSet::new(); + assert_eq!(allocate_name("art skill", &mut names).0, "art-skill"); + assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-2"); + assert_eq!(allocate_name("art skill", &mut names).0, "art-skill-3"); + } + + #[test] + fn parse_mcp_toml_splits_each_server() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("config.toml"); + fs::write( + &path, + "[mcp_servers.search]\ncommand = \"search\"\n\n[mcp_servers.filesystem]\ncommand = \"filesystem\"\n", + ) + .expect("write fixture"); + let servers = parse_mcp_config_file(&path).expect("parse MCP config"); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].0, "filesystem"); + assert_eq!(servers[1].0, "search"); + } + + #[test] + fn parse_mcp_json_splits_each_server() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join(".mcp.json"); + fs::write( + &path, + r#"{"mcpServers":{"search":{"command":"search"},"filesystem":{"command":"filesystem"}}}"#, + ) + .expect("write fixture"); + let servers = parse_mcp_config_file(&path).expect("parse MCP JSON config"); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].0, "filesystem"); + assert_eq!(servers[1].0, "search"); + } + + #[test] + fn single_skill_without_frontmatter_uses_stable_fallback_name() { + let directory = tempfile::tempdir().expect("temp directory"); + let staging = directory.path().join(".staging-test"); + fs::create_dir_all(&staging).expect("create staging directory"); + let path = staging.join("SKILL.md"); + fs::write(&path, "# Skill without frontmatter\n").expect("write skill"); + assert_eq!(parse_skill_name(&path), "skill"); + } + + #[test] + fn fixture_directory_splits_independent_skill_and_mcp_items() { + let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("direct_extensions") + .join("mixed-source"); + let candidates = discover_candidates(&fixture_root).expect("discover mixed fixture"); + let names = candidates + .iter() + .map(|candidate| { + ( + candidate.extension_type.as_str(), + candidate.original_name.as_str(), + ) + }) + .collect::>(); + assert!(names.contains(&("skill", "mixed-skill"))); + assert!(names.contains(&("mcp", "filesystem"))); + assert!(names.contains(&("mcp", "search"))); + assert_eq!(candidates.len(), 3); + } + + #[test] + fn zip_path_traversal_is_rejected() { + let directory = tempfile::tempdir().expect("temp directory"); + let zip_path = directory.path().join("bad.zip"); + let file = fs::File::create(&zip_path).expect("create zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file("../outside.txt", zip::write::SimpleFileOptions::default()) + .expect("start entry"); + writer.write_all(b"outside").expect("write entry"); + writer.finish().expect("finish zip"); + let error = extract_zip(&zip_path, &directory.path().join("staging")) + .expect_err("reject traversal"); + assert!(error.contains("越界")); + assert!(!directory.path().join("outside.txt").exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d7657c796..b52c3828a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -55,6 +55,7 @@ mod command_exec; mod command_output; mod command_sandbox; mod command_sandbox_trampoline; +mod client_extensions; mod commands; mod config; mod context_compaction; @@ -92,6 +93,7 @@ use collaboration::*; use command_exec::*; use command_output::*; use command_sandbox::*; +use client_extensions::*; use commands::*; use config::*; use context_compaction::*; @@ -2225,6 +2227,13 @@ fn main() { inspect_local_project_directory, pick_local_project_directory, pick_local_file, + pick_client_extension_file, + pick_client_extension_directory, + list_client_extensions, + import_client_extension, + set_client_extension_enabled, + rename_client_extension, + remove_client_extension, open_local_project_directory, open_local_project_plan_gdd_markdown, control_agent_run, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 23474fb71..89b16083a 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -50,6 +50,27 @@ export type LauncherImportedAttachment = { error?: string; }; +export type ClientExtensionType = 'skill' | 'mcp' | 'unknown'; + +export type ClientExtensionItem = { + id: string; + name: string; + originalName: string; + extensionType: ClientExtensionType; + sourceName: string; + sourceRelativePath: string; + enabled: boolean; + status: 'enabled' | 'disabled' | 'unknown' | 'startup-failed'; + lastError: string | null; +}; + +export type ClientExtensionImportResult = { + imported: ClientExtensionItem[]; + sourceName: string; + renamed: boolean; + duplicate: boolean; +}; + export type LocalProjectKind = 'web' | 'godot'; export type ProjectStartMode = 'planning' | 'direct-build'; diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 386c6d2cf..81f34e29b 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -5,10 +5,13 @@ import { CircleAlert, Info, LoaderCircle, + Pencil, RotateCcw, Save, Settings2, SlidersHorizontal, + Trash2, + Upload, X, } from 'lucide-react'; import { type FormEvent, useEffect, useRef, useState } from 'react'; @@ -23,6 +26,8 @@ import { } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import { + type ClientExtensionImportResult, + type ClientExtensionItem, type GameCreatorAgentLlmConfig, type GameCreatorAppConfig, type GameCreatorAppConfigView, @@ -86,6 +91,7 @@ type RuntimeSettingsSection = | 'general' | 'agents' | 'connections' + | 'extensions' | 'advanced' | 'about'; @@ -113,6 +119,12 @@ const runtimeSettingsSections = [ description: '外部服务', icon: Cable, }, + { + id: 'extensions', + label: '扩展', + description: 'Skill 与 MCP', + icon: Upload, + }, { id: 'advanced', label: '高级参数', @@ -383,6 +395,15 @@ export function RuntimeConfigDialog({ const [activeSection, setActiveSection] = useState('general'); const [expandedAgentIds, setExpandedAgentIds] = useState([]); + const [clientExtensions, setClientExtensions] = useState< + ClientExtensionItem[] + >([]); + const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false); + const [clientExtensionsStatus, setClientExtensionsStatus] = useState(''); + const [editingExtensionId, setEditingExtensionId] = useState( + null, + ); + const [editingExtensionName, setEditingExtensionName] = useState(''); const runtimeConfigBusyRef = useRef(false); useEscapeToClose(onClose); @@ -401,10 +422,171 @@ export function RuntimeConfigDialog({ useEffect(() => { void readRuntimeConfig(); + void readClientExtensions(); // The dialog reads once on mount; subsequent reads are explicit user actions. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + async function readClientExtensions() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setClientExtensionsStatus('需要在 Tauri App 内运行'); + return; + } + setClientExtensionsBusy(true); + try { + const result = await invoke( + 'list_client_extensions', + ); + setClientExtensions(result); + setClientExtensionsStatus(''); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function importClientExtension(kind: 'file' | 'directory') { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setClientExtensionsStatus('需要在 Tauri App 内运行'); + return; + } + if (clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + setClientExtensionsStatus('正在导入'); + try { + const selectedPath = + kind === 'file' + ? await invoke('pick_client_extension_file') + : await invoke('pick_client_extension_directory'); + if (!selectedPath) { + setClientExtensionsStatus(''); + return; + } + const result = await invoke( + 'import_client_extension', + { sourcePath: selectedPath }, + ); + const refreshed = await invoke( + 'list_client_extensions', + ); + setClientExtensions(refreshed); + const importedCount = result.imported.length; + setClientExtensionsStatus( + importedCount > 0 + ? `已导入 ${importedCount} 个扩展${result.renamed ? ',同名项已自动追加编号' : ''}` + : '未发现可管理的扩展', + ); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + function beginRenameClientExtension(item: ClientExtensionItem) { + setEditingExtensionId(item.id); + setEditingExtensionName(item.name); + setClientExtensionsStatus(''); + } + + function cancelRenameClientExtension() { + setEditingExtensionId(null); + setEditingExtensionName(''); + } + + async function saveClientExtensionName(item: ClientExtensionItem) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + try { + const updated = await invoke( + 'rename_client_extension', + { id: item.id, name: editingExtensionName }, + ); + setClientExtensions((current) => + current.map((candidate) => + candidate.id === updated.id ? updated : candidate, + ), + ); + const requestedName = editingExtensionName.trim(); + setClientExtensionsStatus( + updated.name === requestedName + ? '扩展名称已更新' + : `扩展名称已调整为“${updated.name}”`, + ); + cancelRenameClientExtension(); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function setClientExtensionEnabled( + item: ClientExtensionItem, + enabled: boolean, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy || item.extensionType === 'unknown') { + return; + } + setClientExtensionsBusy(true); + try { + const updated = await invoke( + 'set_client_extension_enabled', + { id: item.id, enabled }, + ); + setClientExtensions((current) => + current.map((candidate) => + candidate.id === updated.id ? updated : candidate, + ), + ); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + + async function removeClientExtension(item: ClientExtensionItem) { + const invoke = resolveTauriInvoke(); + if (!invoke || clientExtensionsBusy) { + return; + } + setClientExtensionsBusy(true); + try { + await invoke('remove_client_extension', { id: item.id }); + setClientExtensions((current) => + current.filter((candidate) => candidate.id !== item.id), + ); + if (editingExtensionId === item.id) { + cancelRenameClientExtension(); + } + setClientExtensionsStatus('扩展已移除'); + } catch (error) { + setClientExtensionsStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + setClientExtensionsBusy(false); + } + } + function updateRuntimeLlmConfig( key: K, value: GameCreatorAppConfig['llm'][K], @@ -690,6 +872,25 @@ export function RuntimeConfigDialog({ {activeSection === 'agents' ? ( {configuredAgentCount} 个角色已覆盖 + ) : activeSection === 'extensions' ? ( +
+ + +
) : null}
@@ -1272,6 +1473,146 @@ export function RuntimeConfigDialog({ ) : null} ) : null} + {activeSection === 'extensions' ? ( +
+ {clientExtensionsStatus ? ( +

+ {clientExtensionsStatus} +

+ ) : null} + {clientExtensions.length > 0 ? ( +
+ {clientExtensions.map((item) => { + const editing = editingExtensionId === item.id; + const typeLabel = + item.extensionType === 'skill' + ? 'Skill' + : item.extensionType === 'mcp' + ? 'MCP' + : '未识别'; + const statusLabel = + item.status === 'enabled' + ? '已启用' + : item.status === 'disabled' + ? '已禁用' + : item.status === 'startup-failed' + ? '启动失败' + : '当前不可用'; + return ( +
+
+ {editing ? ( + + setEditingExtensionName( + event.currentTarget.value, + ) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + void saveClientExtensionName(item); + } else if (event.key === 'Escape') { + event.stopPropagation(); + cancelRenameClientExtension(); + } + }} + /> + ) : ( + {item.name} + )} + + {typeLabel} · 来自 {item.sourceName} + + {item.lastError ? ( + + {item.lastError} + + ) : null} +
+
+ {statusLabel} + {editing ? ( + <> + + + + ) : ( + + )} + {item.extensionType === 'unknown' ? null : ( + + )} + +
+
+ ); + })} +
+ ) : ( +
+ 还没有导入扩展 + + 导入 Skill、MCP 配置或标准 Plugin 后会显示在这里。 + +
+ )} +
+ ) : null} {activeSection === 'about' ? (
span, +.runtime-settings-extension-main > small, +.runtime-settings-extension-actions > span { + overflow: hidden; + color: var(--platform-text-soft); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-settings-extension-main > small { + color: var(--platform-danger, #b45309); +} + +.runtime-settings-extension-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-shrink: 0; +} + +.runtime-settings-extension-actions > span { + margin-right: 2px; +} + +.runtime-settings-empty-state { + display: grid; + gap: 4px; + padding: 24px 14px; + border: 1px dashed var(--platform-subpanel-border); + border-radius: 12px; + color: var(--platform-text-soft); + text-align: center; +} + +.runtime-settings-empty-state strong { + color: var(--platform-text-base); + font-size: 13px; +} + +.runtime-settings-empty-state span { + font-size: 11px; +} + .runtime-settings-fields { align-content: start; } @@ -4492,6 +4622,24 @@ iframe.preview-frame { padding: 18px 14px 22px; } + .runtime-settings-section-header { + align-items: flex-start; + flex-direction: column; + } + + .runtime-settings-section-actions { + justify-content: flex-start; + } + + .runtime-settings-extension-item { + align-items: stretch; + flex-direction: column; + } + + .runtime-settings-extension-actions { + justify-content: flex-start; + } + .runtime-agent-card-fields { grid-template-columns: 1fr; } diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md index 1bbdba7b5..bdbb7d208 100644 --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md @@ -388,6 +388,8 @@ remove_client_extension(id) ### 阶段 1:客户端导入和列表 +当前实施状态:已完成。客户端侧已落地本地扩展索引、来源副本、文件/目录/zip 导入、标准 Skill/MCP 结构发现、独立项拆分、原生命名、重复导入命名、启用/禁用、重命名、删除和设置弹窗列表。DirectProject 运行时注入仍留在阶段 2/3,不属于本阶段。阶段 1 定向 Rust 测试 6 项全部通过,前端 `typecheck` 和配置契约检查通过。 + 完成: - 客户端存储;