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 fe77f4532..f6764b155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -284,6 +284,7 @@ mod resource_inspect; mod resource_preview_scheduler; mod runner; mod swarm_cli; +mod template_library; mod tool_plan_handoff; mod user_input; mod windows; @@ -323,6 +324,7 @@ use resource_inspect::*; use resource_preview_scheduler::*; use runner::*; use swarm_cli::*; +use template_library::*; use user_input::*; use windows::*; #[tauri::command] @@ -2660,7 +2662,10 @@ fn main() { start_game_creator_external_mcp, stop_game_creator_external_mcp, create_automatic_local_game_project, + create_automatic_local_game_project_from_template, init_local_game_project, + fetch_game_template_library, + download_game_template, import_local_godot_project, import_local_cocos_project, is_local_project_directory_non_empty, diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs new file mode 100644 index 000000000..00e0b9c96 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -0,0 +1,1031 @@ +//! AGC 游戏模板库:读取公共 OSS 模板清单、下载模板 zip、安装到本机并据此建项目。 +//! +//! 边界:模板库只做「远端清单 → 本机安装 → 复制进新项目」这条路,不生成模板内容, +//! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀; +//! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 + +use super::*; +use serde::{Deserialize, Serialize}; + +const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1"; +const TEMPLATE_LIBRARY_INDEX_KEY: &str = "templates/index.json"; +const TEMPLATE_LIBRARY_OBJECT_PREFIX: &str = "templates/"; +const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; +const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str = + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com"; +const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL"; +const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; +const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed"; +const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json"; +const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json"; +const TEMPLATE_LIBRARY_MAX_INDEX_BYTES: u64 = 4 * 1024 * 1024; +const TEMPLATE_ARCHIVE_MAX_BYTES: u64 = 512 * 1024 * 1024; +const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096; +const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; +const TEMPLATE_ID_MAX_CHARS: usize = 64; +const TEMPLATE_VERSION_MAX_CHARS: usize = 32; + +/// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。 +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateSummary { + pub(crate) id: String, + pub(crate) title: String, + #[serde(default)] + pub(crate) summary: String, + #[serde(default)] + pub(crate) tags: Vec, + #[serde(default)] + pub(crate) runtime: String, + #[serde(default)] + pub(crate) engine: String, + #[serde(default)] + pub(crate) engine_version: String, + pub(crate) template_version: String, + #[serde(default)] + pub(crate) updated_at: String, + #[serde(default)] + pub(crate) entry: String, + pub(crate) zip_key: String, + pub(crate) zip_size_bytes: u64, + pub(crate) zip_sha256: String, + pub(crate) cover_key: String, + #[serde(default)] + pub(crate) cover_width: u32, + #[serde(default)] + pub(crate) cover_height: u32, + #[serde(default)] + pub(crate) cover_sha256: String, + #[serde(default)] + pub(crate) metadata_key: String, +} + +/// `templates/index.json` 的库头信息。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibraryHeader { + pub(crate) schema_version: String, + #[serde(default)] + pub(crate) library: String, + #[serde(default)] + pub(crate) library_version: u32, + #[serde(default)] + pub(crate) updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +struct GameTemplateLibraryIndex { + #[serde(flatten)] + header: GameTemplateLibraryHeader, + #[serde(default)] + templates: Vec, +} + +/// 返回给前端的模板条目:清单字段 + 远端地址 + 本机安装状态。 +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateEntry { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) tags: Vec, + pub(crate) runtime: String, + pub(crate) engine: String, + pub(crate) engine_version: String, + pub(crate) template_version: String, + pub(crate) updated_at: String, + pub(crate) entry: String, + pub(crate) zip_url: String, + pub(crate) zip_size_bytes: u64, + pub(crate) zip_sha256: String, + pub(crate) cover_url: String, + pub(crate) cover_width: u32, + pub(crate) cover_height: u32, + pub(crate) installed: bool, + pub(crate) installed_version: Option, + pub(crate) installed_at_millis: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibrarySnapshot { + pub(crate) schema_version: String, + pub(crate) library: String, + pub(crate) library_version: u32, + pub(crate) updated_at: String, + pub(crate) fetched_at_millis: u64, + /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 + pub(crate) source: String, + pub(crate) templates: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct InstalledGameTemplateRecord { + template_id: String, + template_version: String, + installed_at_millis: u64, + zip_sha256: String, + file_count: usize, + project_dir: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstalledGameTemplate { + pub(crate) template_id: String, + pub(crate) template_version: String, + pub(crate) installed_at_millis: u64, + pub(crate) zip_sha256: String, + pub(crate) file_count: usize, + pub(crate) project_dir: String, +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +fn template_library_base_url() -> String { + std::env::var(TEMPLATE_LIBRARY_BASE_URL_ENV) + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_TEMPLATE_LIBRARY_BASE_URL.to_string()) +} + +/// 只接受受信任 OSS 主机下的 HTTPS 地址;返回去掉尾斜杠的 base。 +pub(crate) fn validated_template_library_base_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + let parsed = url::Url::parse(trimmed).map_err(|_| "模板库地址无效".to_string())?; + if parsed.scheme() != "https" || parsed.host_str() != Some(TEMPLATE_LIBRARY_OSS_HOST) { + return Err("模板库地址必须来自受信任的 OSS".to_string()); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err("模板库地址只能包含主机名".to_string()); + } + Ok(trimmed.to_string()) +} + +fn template_library_base() -> Result { + validated_template_library_base_url(&template_library_base_url()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn is_valid_sha256(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_template_identifier(value: &str, max_chars: usize, label: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().count() > max_chars { + return Err(format!("{label}无效")); + } + if !trimmed.chars().all(|value| { + value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.') + }) { + return Err(format!("{label}无效")); + } + if trimmed.contains("..") { + return Err(format!("{label}无效")); + } + Ok(()) +} + +/// 远端对象键必须落在 `templates/` 前缀下,且不含绝对路径、上跳或反斜杠。 +pub(crate) fn validate_template_object_key(key: &str) -> Result<(), String> { + let trimmed = key.trim(); + if trimmed.is_empty() || !trimmed.starts_with(TEMPLATE_LIBRARY_OBJECT_PREFIX) { + return Err("模板对象键必须位于 templates/ 前缀下".to_string()); + } + if trimmed.starts_with('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("模板对象键无效".to_string()); + } + if trimmed + .chars() + .any(|value| value.is_control() || value == ' ' || value == '?') + { + return Err("模板对象键无效".to_string()); + } + Ok(()) +} + +fn template_object_url(key: &str) -> Result { + validate_template_object_key(key)?; + Ok(format!("{}/{}", template_library_base()?, key.trim())) +} + +fn validate_template_summary(entry: &GameTemplateSummary) -> Result<(), String> { + validate_template_identifier(&entry.id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?; + validate_template_identifier( + &entry.template_version, + TEMPLATE_VERSION_MAX_CHARS, + "模板版本", + )?; + if entry.title.trim().is_empty() || entry.title.chars().count() > 120 { + return Err(format!("模板 {} 的标题无效", entry.id)); + } + if entry.tags.len() > 32 { + return Err(format!("模板 {} 的标签过多", entry.id)); + } + validate_template_object_key(&entry.zip_key)?; + validate_template_object_key(&entry.cover_key)?; + if !entry.metadata_key.trim().is_empty() { + validate_template_object_key(&entry.metadata_key)?; + } + if entry.zip_size_bytes == 0 || entry.zip_size_bytes > TEMPLATE_ARCHIVE_MAX_BYTES { + return Err(format!("模板 {} 的包大小无效", entry.id)); + } + if !is_valid_sha256(&entry.zip_sha256) { + return Err(format!("模板 {} 的包摘要无效", entry.id)); + } + if !entry.cover_sha256.trim().is_empty() && !is_valid_sha256(&entry.cover_sha256) { + return Err(format!("模板 {} 的封面摘要无效", entry.id)); + } + Ok(()) +} + +/// 解析并校验远端清单;任何一条不合法都让整次读取失败,避免前端拿到半可信数据。 +pub(crate) fn parse_game_template_library_index( + body: &str, +) -> Result<(GameTemplateLibraryHeader, Vec), String> { + let index: GameTemplateLibraryIndex = + serde_json::from_str(body).map_err(|error| format!("模板库清单不是有效 JSON:{error}"))?; + if index.header.schema_version != TEMPLATE_LIBRARY_SCHEMA_VERSION { + return Err("模板库清单版本不受支持".to_string()); + } + let mut seen = std::collections::BTreeSet::new(); + for entry in &index.templates { + validate_template_summary(entry)?; + if !seen.insert(entry.id.clone()) { + return Err(format!("模板库清单存在重复模板:{}", entry.id)); + } + } + Ok((index.header, index.templates)) +} + +fn template_cache_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +fn installed_templates_root(cache_root: &Path) -> PathBuf { + cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME) +} + +fn installed_template_dir( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + validate_template_identifier(template_id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?; + validate_template_identifier(template_version, TEMPLATE_VERSION_MAX_CHARS, "模板版本")?; + Ok(installed_templates_root(cache_root) + .join(template_id.trim()) + .join(template_version.trim())) +} + +fn read_installed_record(directory: &Path) -> Option { + let body = fs::read_to_string(directory.join(TEMPLATE_INSTALLED_MARKER_FILE)).ok()?; + serde_json::from_str(&body).ok() +} + +/// 扫描本机已安装模板,返回 `(模板 ID, 安装记录)`;损坏或缺少标记的目录视为未安装。 +fn collect_installed_records(cache_root: &Path) -> Vec { + let root = installed_templates_root(cache_root); + let Ok(template_entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut records = Vec::new(); + for template_entry in template_entries.flatten() { + let Ok(version_entries) = fs::read_dir(template_entry.path()) else { + continue; + }; + for version_entry in version_entries.flatten() { + if let Some(record) = read_installed_record(&version_entry.path()) { + records.push(record); + } + } + } + records +} + +fn installed_record_for( + records: &[InstalledGameTemplateRecord], + template_id: &str, +) -> Option { + records + .iter() + .filter(|record| record.template_id == template_id) + .max_by_key(|record| record.installed_at_millis) + .cloned() +} + +fn to_entry( + summary: &GameTemplateSummary, + installed: Option<&InstalledGameTemplateRecord>, +) -> Result { + Ok(GameTemplateEntry { + id: summary.id.clone(), + title: summary.title.clone(), + summary: summary.summary.clone(), + tags: summary.tags.clone(), + runtime: summary.runtime.clone(), + engine: summary.engine.clone(), + engine_version: summary.engine_version.clone(), + template_version: summary.template_version.clone(), + updated_at: summary.updated_at.clone(), + entry: summary.entry.clone(), + zip_url: template_object_url(&summary.zip_key)?, + zip_size_bytes: summary.zip_size_bytes, + zip_sha256: summary.zip_sha256.to_ascii_lowercase(), + cover_url: template_object_url(&summary.cover_key)?, + cover_width: summary.cover_width, + cover_height: summary.cover_height, + installed: installed.is_some(), + installed_version: installed.map(|record| record.template_version.clone()), + installed_at_millis: installed.map(|record| record.installed_at_millis), + }) +} + +fn build_template_library_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +async fn fetch_limited_bytes( + client: &reqwest::Client, + url: &str, + max_bytes: u64, +) -> Result, String> { + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("请求模板库失败:{error}"))?; + if !response.status().is_success() { + return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes) + { + return Err("模板库对象超过大小限制".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("读取模板库对象失败:{error}"))?; + if body.len() as u64 + chunk.len() as u64 > max_bytes { + return Err("模板库对象超过大小限制".to_string()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn write_cached_index(cache_root: &Path, body: &str) { + let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); + let _ = write_game_creator_private_file( + &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), + body.as_bytes(), + "模板库清单缓存", + ); +} + +fn read_cached_index(cache_root: &Path) -> Option { + fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok() +} + +/// 单个 zip 条目路径:只允许相对普通路径,禁止绝对路径、上跳、反斜杠与盘符。 +pub(crate) fn safe_archive_relative_path(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("模板包条目名为空".to_string()); + } + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') || normalized.contains(':') { + return Err(format!("模板包条目路径无效:{raw}")); + } + let mut path = PathBuf::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return Err(format!("模板包条目路径无效:{raw}")); + } + path.push(segment); + } + if path.as_os_str().is_empty() { + return Err(format!("模板包条目路径无效:{raw}")); + } + Ok(path) +} + +fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| format!("模板包不是有效 zip:{error}"))?; + if archive.len() > TEMPLATE_ARCHIVE_MAX_FILES { + return Err("模板包文件数量超过上限".to_string()); + } + let mut written = 0_usize; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取模板包条目失败:{error}"))?; + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("模板包不允许包含符号链接".to_string()); + } + let relative = safe_archive_relative_path(entry.name())?; + let target = destination.join(&relative); + if entry.is_dir() { + ensure_game_creator_private_directory_tree(&target, "模板目录")?; + continue; + } + if entry.size() > TEMPLATE_ARCHIVE_MAX_FILE_BYTES { + return Err(format!("模板包文件超过大小上限:{}", entry.name())); + } + let mut buffer = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut buffer) + .map_err(|error| format!("读取模板包文件失败:{error}"))?; + if let Some(parent) = target.parent() { + ensure_game_creator_private_directory_tree(parent, "模板目录")?; + } + write_game_creator_private_file(&target, &buffer, "模板文件")?; + written += 1; + } + if written == 0 { + return Err("模板包没有可写入的文件".to_string()); + } + Ok(written) +} + +fn install_template_archive( + cache_root: &Path, + summary: &GameTemplateSummary, + bytes: &[u8], +) -> Result { + if bytes.len() as u64 != summary.zip_size_bytes { + return Err("模板包大小校验失败".to_string()); + } + let actual_sha256 = sha256_hex(bytes); + if actual_sha256 != summary.zip_sha256.trim().to_ascii_lowercase() { + return Err("模板包完整性校验失败".to_string()); + } + let directory = installed_template_dir(cache_root, &summary.id, &summary.template_version)?; + if directory.exists() { + // 只清理本模板自己的安装目录;路径由标识符白名单拼出,不含远端输入。 + let _ = fs::remove_dir_all(&directory); + } + ensure_game_creator_private_directory_tree(&directory, "模板安装目录")?; + let file_count = extract_template_archive(bytes, &directory)?; + let record = InstalledGameTemplateRecord { + template_id: summary.id.clone(), + template_version: summary.template_version.clone(), + installed_at_millis: now_millis(), + zip_sha256: actual_sha256, + file_count, + project_dir: directory.to_string_lossy().into_owned(), + }; + let body = serde_json::to_string_pretty(&record) + .map_err(|error| format!("写入模板安装记录失败:{error}"))?; + write_game_creator_private_file( + &directory.join(TEMPLATE_INSTALLED_MARKER_FILE), + body.as_bytes(), + "模板安装记录", + )?; + Ok(record) +} + +fn copy_template_project(source_dir: &Path, target_root: &Path) -> Result { + let mut copied = 0_usize; + let mut stack = vec![source_dir.to_path_buf()]; + while let Some(directory) = stack.pop() { + let entries = + fs::read_dir(&directory).map_err(|error| format!("读取模板目录失败:{error}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if entry.file_name() == TEMPLATE_INSTALLED_MARKER_FILE { + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("读取模板条目失败:{error}"))?; + if metadata.is_dir() { + stack.push(path); + continue; + } + if !metadata.is_file() { + return Err(format!("模板包含不支持的条目:{}", path.display())); + } + let relative = path + .strip_prefix(source_dir) + .map_err(|error| format!("模板条目路径无效:{error}"))?; + let target = target_root.join(relative); + if let Some(parent) = target.parent() { + ensure_game_creator_private_directory_tree(parent, "项目模板目录")?; + } + let bytes = fs::read(&path).map_err(|error| format!("读取模板文件失败:{error}"))?; + write_game_creator_private_file(&target, &bytes, "项目模板文件")?; + copied += 1; + } + } + if copied == 0 { + return Err("模板没有可复制的文件".to_string()); + } + Ok(copied) +} + +fn find_template_summary( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + let body = read_cached_index(cache_root).ok_or_else(|| "本机没有模板库清单缓存".to_string())?; + let (_, templates) = parse_game_template_library_index(&body)?; + templates + .into_iter() + .find(|entry| entry.id == template_id && entry.template_version == template_version) + .ok_or_else(|| "模板库清单里没有该模板版本".to_string()) +} + +async fn ensure_template_installed( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + let installed_directory = installed_template_dir(cache_root, template_id, template_version)?; + if let Some(record) = read_installed_record(&installed_directory) { + return Ok(record); + } + let summary = find_template_summary(cache_root, template_id, template_version)?; + let client = build_template_library_client(); + let url = template_object_url(&summary.zip_key)?; + let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?; + install_template_archive(cache_root, &summary, &bytes) +} + +#[tauri::command] +pub(crate) async fn fetch_game_template_library( + app: tauri::AppHandle, +) -> Result { + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let index_url = format!( + "{}/{}", + template_library_base()?, + TEMPLATE_LIBRARY_INDEX_KEY + ); + let client = build_template_library_client(); + let (body, source) = + match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + Ok(bytes) => { + let body = + String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; + parse_game_template_library_index(&body)?; + write_cached_index(&cache_root, &body); + (body, "network") + } + Err(error) => match read_cached_index(&cache_root) { + Some(cached) => { + parse_game_template_library_index(&cached)?; + (cached, "cache") + } + None => return Err(error), + }, + }; + let (header, templates) = parse_game_template_library_index(&body)?; + let installed = collect_installed_records(&cache_root); + let entries = templates + .iter() + .map(|summary| { + to_entry( + summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + }) + .collect::, _>>()?; + Ok(GameTemplateLibrarySnapshot { + schema_version: header.schema_version, + library: header.library, + library_version: header.library_version, + updated_at: header.updated_at, + fetched_at_millis: now_millis(), + source: source.to_string(), + templates: entries, + }) +} + +#[tauri::command] +pub(crate) async fn download_game_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, +) -> Result { + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = + ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + Ok(InstalledGameTemplate { + template_id: record.template_id, + template_version: record.template_version, + installed_at_millis: record.installed_at_millis, + zip_sha256: record.zip_sha256, + file_count: record.file_count, + project_dir: record.project_dir, + }) +} + +/// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。 +pub(crate) fn create_project_from_installed_template_at( + projects_root: &Path, + installed_project_dir: &Path, + requested_name: Option<&str>, + planning: bool, +) -> Result { + let requested_name = requested_name + .map(normalize_game_creation_project_name) + .transpose()?; + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + if !installed_project_dir.is_dir() { + return Err("模板尚未安装到本机".to_string()); + } + ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?; + prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = requested_name.clone().unwrap_or_else(|| { + let prefix = if planning { + "策划项目" + } else { + "GameAgent 项目" + }; + format!("{prefix} {short_id}") + }); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?; + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + copy_template_project(installed_project_dir, &project_root)?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) async fn create_automatic_local_game_project_from_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, + name: Option, + planning: Option, +) -> Result { + let projects_root = app + .path() + .app_data_dir() + .map(|root| root.join("projects")) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = + ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_index_body() -> String { + serde_json::json!({ + "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, + "library": "agc-game-templates", + "libraryVersion": 1, + "updatedAt": "2026-09-17T00:00:00Z", + "templates": [ + { + "id": "demo-template", + "title": "演示模板", + "summary": "用于测试", + "tags": ["2d", "demo"], + "runtime": "html", + "engine": "phaser", + "engineVersion": "4.2.1", + "templateVersion": "1.0.0", + "updatedAt": "2026-09-17T00:00:00Z", + "entry": "game/index.html", + "zipKey": "templates/v1/demo-template/template.zip", + "zipSizeBytes": 128, + "zipSha256": "0".repeat(64), + "coverKey": "templates/v1/demo-template/cover.png", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "1".repeat(64), + "metadataKey": "templates/v1/demo-template/template.json" + } + ] + }) + .to_string() + } + + fn sample_summary() -> GameTemplateSummary { + parse_game_template_library_index(&sample_index_body()) + .expect("parse sample index") + .1 + .remove(0) + } + + fn build_archive(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("start file"); + writer.write_all(bytes).expect("write file"); + } + writer.finish().expect("finish archive").into_inner() + } + + /// 自动工作区根目录要走私有 DACL 校验,和 `tests::unique_project_path` 一样 + /// 在临时目录下取一个尚未存在的唯一路径,而不是用 tempdir 预先建好的目录。 + fn unique_projects_root() -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_millis(); + std::env::temp_dir().join(format!( + "genarrative-agc-template-library-test-{}-{millis}", + std::process::id() + )) + } + + #[test] + fn rejects_index_with_unsupported_schema_or_duplicate_templates() { + let unsupported = + sample_index_body().replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2"); + assert!(parse_game_template_library_index(&unsupported).is_err()); + + let entry = serde_json::to_string(&sample_summary()).expect("serialize summary"); + let duplicated = serde_json::json!({ + "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, + "templates": [ + serde_json::from_str::(&entry).expect("value"), + serde_json::from_str::(&entry).expect("value"), + ] + }) + .to_string(); + let error = parse_game_template_library_index(&duplicated).expect_err("duplicate rejected"); + assert!(error.contains("重复模板"), "{error}"); + } + + #[test] + fn rejects_object_keys_outside_the_templates_prefix() { + assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err()); + assert!(validate_template_object_key("templates/../secret").is_err()); + assert!(validate_template_object_key("/templates/a.zip").is_err()); + assert!(validate_template_object_key("templates\\a.zip").is_err()); + assert!(validate_template_object_key("templates/v1/demo/template.zip").is_ok()); + } + + #[test] + fn rejects_template_base_url_outside_trusted_oss() { + assert!(validated_template_library_base_url( + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com" + ) + .is_ok()); + assert!(validated_template_library_base_url( + "http://agc-dev.oss-rg-china-mainland.aliyuncs.com" + ) + .is_err()); + assert!(validated_template_library_base_url("https://evil.example.com").is_err()); + assert!(validated_template_library_base_url( + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/other-prefix" + ) + .is_err()); + } + + /// 固定 2026-09-17 实际发布的 `templates/index.json`:客户端解析必须与线上契约一致。 + #[test] + fn parses_the_published_library_index_fixture() { + let body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (header, templates) = + parse_game_template_library_index(body).expect("parse published index"); + assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION); + assert_eq!(header.library, "agc-game-templates"); + assert_eq!( + templates + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["phaser-2d-starter", "threejs-3d-starter"] + ); + let first = templates.first().expect("first template"); + assert_eq!(first.runtime, "html"); + assert_eq!(first.tags.first().map(String::as_str), Some("起步工程")); + assert!(first.cover_width > 0 && first.cover_height > 0); + + let entry = to_entry(first, None).expect("build entry"); + let base = template_library_base().expect("trusted base"); + assert_eq!( + entry.zip_url, + format!("{base}/templates/v1/phaser-2d-starter/template.zip") + ); + assert_eq!( + entry.cover_url, + format!("{base}/templates/v1/phaser-2d-starter/cover.png") + ); + assert!(!entry.installed); + } + + #[test] + fn rejects_archive_entries_that_escape_the_destination() { + assert!(safe_archive_relative_path("game/index.html").is_ok()); + assert!(safe_archive_relative_path("./game/index.html").is_ok()); + assert!(safe_archive_relative_path("../escape.txt").is_err()); + assert!(safe_archive_relative_path("game/../../escape.txt").is_err()); + assert!(safe_archive_relative_path("C:/escape.txt").is_err()); + assert!(safe_archive_relative_path("/escape.txt").is_err()); + + let archive = build_archive(&[("../escape.txt", b"nope")]); + let destination = tempfile::tempdir().expect("temp dir"); + assert!(extract_template_archive(&archive, destination.path()).is_err()); + assert!(!destination + .path() + .parent() + .unwrap() + .join("escape.txt") + .exists()); + } + + #[test] + fn installs_template_archive_and_reports_it_as_installed() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let archive = build_archive(&[ + ("game/index.html", b""), + ("game/game.js", b"console.log('demo');"), + ]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install template"); + assert_eq!(record.file_count, 2); + let installed = collect_installed_records(cache_root.path()); + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].template_version, "1.0.0"); + + let entry = to_entry( + &summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + .expect("build entry"); + assert!(entry.installed); + assert_eq!(entry.installed_version.as_deref(), Some("1.0.0")); + assert!(entry.zip_url.starts_with("https://")); + assert!(entry + .cover_url + .ends_with("/templates/v1/demo-template/cover.png")); + } + + #[test] + fn rejects_archive_when_size_or_digest_do_not_match_the_index() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let archive = build_archive(&[("game/index.html", b"")]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64 + 1; + let error = install_template_archive(cache_root.path(), &summary, &archive) + .expect_err("size mismatch rejected"); + assert!(error.contains("大小校验失败"), "{error}"); + + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = "f".repeat(64); + let error = install_template_archive(cache_root.path(), &summary, &archive) + .expect_err("digest mismatch rejected"); + assert!(error.contains("完整性校验失败"), "{error}"); + } + + #[test] + fn creates_project_from_installed_template_without_leaking_install_marker() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let archive = build_archive(&[ + ("game/index.html", b"template"), + ("assets/README.txt", b"template assets"), + ]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("模板项目"), + false, + ) + .expect("create project from template"); + assert_eq!(result.manifest.name, "模板项目"); + let project_root = Path::new(&result.project_path); + let index = + fs::read_to_string(project_root.join("game/index.html")).expect("template file copied"); + assert!(index.contains("template")); + assert!(project_root.join("assets/README.txt").is_file()); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + assert!(!project_root.join("game/index.html.orig").exists()); + fs::remove_dir_all(&projects_root).ok(); + } + + #[test] + fn refuses_to_create_project_when_template_is_not_installed() { + let projects_root = tempfile::tempdir().expect("temp dir"); + let missing = projects_root.path().join("missing-template"); + let error = + create_project_from_installed_template_at(projects_root.path(), &missing, None, false) + .expect_err("missing template rejected"); + assert!(error.contains("模板尚未安装"), "{error}"); + } + + /// 可选的真连检查:`cargo test --bin genarrative-ai-game-creator-shell template_library -- --ignored`。 + /// 默认跳过,避免离线环境因网络失败误报。 + #[tokio::test] + #[ignore = "需要网络:校验客户端能真实读到线上模板库清单与对象键"] + async fn fetches_the_live_template_library_index() { + let base = template_library_base().expect("trusted base"); + let client = build_template_library_client(); + let body = fetch_limited_bytes( + &client, + &format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"), + TEMPLATE_LIBRARY_MAX_INDEX_BYTES, + ) + .await + .expect("fetch live index"); + let body = String::from_utf8(body).expect("utf-8 index"); + let (header, templates) = + parse_game_template_library_index(&body).expect("parse live index"); + assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION); + assert!(!templates.is_empty(), "线上模板库应当至少有一个模板"); + for entry in &templates { + let zip_url = template_object_url(&entry.zip_key).expect("trusted zip url"); + assert!(zip_url.starts_with(&format!("{base}/"))); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 1d4c33053..f27f09c66 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -24,8 +24,8 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", - "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", + "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" } }, "bundle": { diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json new file mode 100644 index 000000000..256670fe0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": "agc-template-library.v1", + "library": "agc-game-templates", + "libraryVersion": 1, + "updatedAt": "2026-09-17T03:06:41Z", + "templates": [ + { + "id": "phaser-2d-starter", + "title": "Phaser 2D 起步工程", + "summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。", + "tags": [ + "起步工程", + "2d", + "phaser", + "像素" + ], + "runtime": "html", + "engine": "phaser", + "engineVersion": "4.2.1", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:06:41Z", + "entry": "game/index.html", + "zipKey": "templates/v1/phaser-2d-starter/template.zip", + "zipSizeBytes": 8823, + "zipSha256": "6d3decc9f770a3e5225c387fb6c907561823a72f2f40da932e71308e6288cc97", + "coverKey": "templates/v1/phaser-2d-starter/cover.png", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "5fcf952aa89c302258fcf462344d7d799a8bd2a79070d5412c94ac269e313376", + "metadataKey": "templates/v1/phaser-2d-starter/template.json" + }, + { + "id": "threejs-3d-starter", + "title": "Three.js 3D 起步工程", + "summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。", + "tags": [ + "起步工程", + "3d", + "three.js", + "网页" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:06:41Z", + "entry": "game/index.html", + "zipKey": "templates/v1/threejs-3d-starter/template.zip", + "zipSizeBytes": 1700, + "zipSha256": "dd353052fd61990ace3aa4bafeb8b48a7121133f33c976dd4865fff9761fc44b", + "coverKey": "templates/v1/threejs-3d-starter/cover.png", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "3a4d3080853737663d970a21a63e9d6b5cb523b31c5463d2c2dd94fddf66f187", + "metadataKey": "templates/v1/threejs-3d-starter/template.json" + } + ] +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c40dca2e4..aea971d8f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -25,8 +25,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import TemplateLibraryView from '../../view/template-library'; import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; +import { useTemplateLibrary } from '../template-library/useTemplateLibrary'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; import { DeveloperAgentDialogs, @@ -83,6 +85,11 @@ export function WorkspaceLauncherShell({ setAgentChatProjectPath: developerAgent.setAgentChatProjectPath, rememberRecentWorkspace, }); + const templateLibrary = useTemplateLibrary({ + onProjectCreated: async (result) => { + await homeProject.enterCreatedTemplateProject(result); + }, + }); const { projectPath, setProjectPath, @@ -567,6 +574,13 @@ export function WorkspaceLauncherShell({ void openProject(path, 'open'); }} onProjectPick={() => void homeProject.pickAndOpenProject()} + templateRecommendations={templateLibrary.templates} + templateLibraryLoading={ + templateLibrary.status === 'loading' || + templateLibrary.status === 'idle' + } + templateLibraryError={templateLibrary.error} + onTemplateLibraryOpen={() => setLauncherView('template-library')} /> ) : launcherView === 'projects' ? ( + ) : launcherView === 'template-library' ? ( + setLauncherView('home')} + /> ) : launcherView === 'agent-chat' ? ( & Pick, +): GameTemplateEntry { + return { + title: '未命名模板', + summary: '', + tags: [], + runtime: 'html', + engine: 'phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'game/index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip', + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png', + coverWidth: 960, + coverHeight: 540, + installed: false, + installedVersion: null, + installedAtMillis: null, + ...overrides, + }; +} + +const matchThree = template({ + id: 'match-3', + title: '三消经营', + summary: '三消与模拟经营的融合模板', + tags: ['三消', '经营'], + engine: 'phaser', + installed: true, + installedVersion: '1.0.0', +}); +const pixelFarm = template({ + id: 'pixel-farm', + title: '像素农场', + summary: '像素风种植玩法', + tags: ['经营', '像素'], + engine: 'godot', + runtime: 'godot', + templateVersion: '2.0.0', + installed: true, + installedVersion: '1.0.0', +}); +const spaceShooter = template({ + id: 'space-shooter', + title: '太空射击', + summary: '纵版弹幕射击', + tags: ['射击'], + runtime: 'unity', + engine: 'unity', + installed: false, +}); + +const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter]; + +describe('templateMatchesQuery', () => { + it('matches title, summary, tags and engine case-insensitively', () => { + expect(templateMatchesQuery(matchThree, '三消')).toBe(true); + expect(templateMatchesQuery(matchThree, '经营')).toBe(true); + expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true); + expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false); + }); + + it('requires every whitespace separated term to match', () => { + expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true); + expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false); + expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true); + }); +}); + +describe('filterGameTemplates', () => { + it('filters by tag, runtime, query and installed state together', () => { + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + tags: ['经营'], + }).map((entry) => entry.id), + ).toEqual(['match-3', 'pixel-farm']); + + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + runtime: 'godot', + }).map((entry) => entry.id), + ).toEqual(['pixel-farm']); + + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + installedOnly: true, + query: '经营', + tags: ['像素'], + }).map((entry) => entry.id), + ).toEqual(['pixel-farm']); + }); + + it('returns everything when no filter is active', () => { + expect( + filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS), + ).toHaveLength(3); + expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe( + true, + ); + expect( + isTemplateLibraryFiltersEmpty({ + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + query: ' x ', + }), + ).toBe(false); + }); +}); + +describe('tag and runtime options', () => { + it('orders tags by frequency and drops blanks', () => { + const withBlank = [ + ...templates, + template({ id: 'blank-tag', tags: ['', ' ', '经营'] }), + ]; + expect(collectGameTemplateTags(withBlank)).toEqual([ + '经营', + '三消', + '射击', + '像素', + ]); + }); + + it('collects distinct runtimes and labels them', () => { + expect(collectGameTemplateRuntimes(templates)).toEqual([ + 'godot', + 'html', + 'unity', + ]); + expect(templateRuntimeLabel('html')).toBe('网页'); + expect(templateRuntimeLabel('cocos')).toBe('Cocos'); + expect(templateRuntimeLabel('')).toBe('未标注运行时'); + expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine'); + }); + + it('toggles tags without mutating the previous filters', () => { + const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营'); + expect(next.tags).toEqual(['经营']); + expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]); + expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]); + }); +}); + +describe('needsTemplateDownload', () => { + it('requires a download when missing or when the installed version is stale', () => { + expect(needsTemplateDownload(matchThree)).toBe(false); + expect(needsTemplateDownload(pixelFarm)).toBe(true); + expect(needsTemplateDownload(spaceShooter)).toBe(true); + }); +}); + +describe('formatGameTemplateSize', () => { + it('formats bytes, kilobytes and megabytes', () => { + expect(formatGameTemplateSize(0)).toBe('--'); + expect(formatGameTemplateSize(512)).toBe('512 B'); + expect(formatGameTemplateSize(2048)).toBe('2.0 KB'); + expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts new file mode 100644 index 000000000..65f460fb9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts @@ -0,0 +1,208 @@ +/** + * AGC 模板库的前端模型:清单类型、搜索与筛选的纯函数。 + * + * 真源在 OSS 清单与 Rust 侧(`fetch_game_template_library`);这里只做展示层派生, + * 不缓存业务真相,也不拼远端地址(URL 由 Rust 侧按受信任 OSS 前缀给出)。 + */ + +export type GameTemplateLibrarySource = 'network' | 'cache'; + +export type GameTemplateEntry = { + id: string; + title: string; + summary: string; + tags: string[]; + runtime: string; + engine: string; + engineVersion: string; + templateVersion: string; + updatedAt: string; + entry: string; + zipUrl: string; + zipSizeBytes: number; + zipSha256: string; + coverUrl: string; + coverWidth: number; + coverHeight: number; + installed: boolean; + installedVersion: string | null; + installedAtMillis: number | null; +}; + +export type GameTemplateLibrarySnapshot = { + schemaVersion: string; + library: string; + libraryVersion: number; + updatedAt: string; + fetchedAtMillis: number; + source: GameTemplateLibrarySource; + templates: GameTemplateEntry[]; +}; + +export type InstalledGameTemplate = { + templateId: string; + templateVersion: string; + installedAtMillis: number; + zipSha256: string; + fileCount: number; + projectDir: string; +}; + +export type TemplateLibraryFilters = { + query: string; + tags: readonly string[]; + runtime: string; + installedOnly: boolean; +}; + +export const EMPTY_TEMPLATE_LIBRARY_FILTERS: TemplateLibraryFilters = { + query: '', + tags: [], + runtime: '', + installedOnly: false, +}; + +const RUNTIME_LABELS: Record = { + html: '网页', + unity: 'Unity', + godot: 'Godot', + cocos: 'Cocos', +}; + +export function templateRuntimeLabel(runtime: string): string { + const normalized = runtime.trim().toLowerCase(); + if (!normalized) return '未标注运行时'; + return RUNTIME_LABELS[normalized] ?? runtime.trim(); +} + +/** + * 空白分隔的多个关键词之间是「与」关系:每个词都必须命中标题、简介、标签或引擎, + * 这样「三消 像素」不会退化成命中任意一个就出现的宽泛搜索。 + */ +export function templateMatchesQuery( + template: GameTemplateEntry, + query: string, +): boolean { + const terms = query + .toLowerCase() + .split(/\s+/u) + .filter((term) => term.length > 0); + if (terms.length === 0) { + return true; + } + const haystack = [ + template.title, + template.summary, + template.engine, + template.runtime, + template.tags.join(' '), + ] + .join(' ') + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +export function filterGameTemplates( + templates: readonly GameTemplateEntry[], + filters: TemplateLibraryFilters, +): GameTemplateEntry[] { + const selectedTags = filters.tags + .map((tag) => tag.trim().toLowerCase()) + .filter((tag) => tag.length > 0); + const runtime = filters.runtime.trim().toLowerCase(); + return templates.filter((template) => { + if (filters.installedOnly && !template.installed) { + return false; + } + if (runtime && template.runtime.trim().toLowerCase() !== runtime) { + return false; + } + if (selectedTags.length > 0) { + const templateTags = template.tags.map((tag) => tag.toLowerCase()); + if (!selectedTags.some((tag) => templateTags.includes(tag))) { + return false; + } + } + return templateMatchesQuery(template, filters.query); + }); +} + +/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */ +export function collectGameTemplateTags( + templates: readonly GameTemplateEntry[], +): string[] { + const counts = new Map(); + for (const template of templates) { + for (const tag of template.tags) { + const trimmed = tag.trim(); + if (!trimmed) continue; + counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort( + ([leftTag, leftCount], [rightTag, rightCount]) => + rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'), + ) + .map(([tag]) => tag); +} + +export function collectGameTemplateRuntimes( + templates: readonly GameTemplateEntry[], +): string[] { + const runtimes = new Set(); + for (const template of templates) { + const runtime = template.runtime.trim().toLowerCase(); + if (runtime) runtimes.add(runtime); + } + return [...runtimes].sort((left, right) => + left.localeCompare(right, 'zh-CN'), + ); +} + +export function formatGameTemplateSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '--'; + } + if (bytes < 1024) { + return `${Math.round(bytes)} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function isTemplateLibraryFiltersEmpty( + filters: TemplateLibraryFilters, +): boolean { + return ( + !filters.query.trim() && + filters.tags.length === 0 && + !filters.runtime.trim() && + !filters.installedOnly + ); +} + +export function toggleGameTemplateTag( + filters: TemplateLibraryFilters, + tag: string, +): TemplateLibraryFilters { + const exists = filters.tags.includes(tag); + return { + ...filters, + tags: exists + ? filters.tags.filter((value) => value !== tag) + : [...filters.tags, tag], + }; +} + +/** + * 已安装版本低于清单版本时必须重新下载;已安装且版本一致才算可直接使用。 + */ +export function needsTemplateDownload(template: GameTemplateEntry): boolean { + return ( + !template.installed || + template.installedVersion !== template.templateVersion + ); +} diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts new file mode 100644 index 000000000..aa1ef20a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -0,0 +1,243 @@ +/** + * 模板库状态链路:拉取清单、下载模板、用模板建项目。 + * + * 远端真相全在 Rust 侧命令里(受信任 OSS 前缀 + 摘要校验 + 本机安装记录); + * 这里只维护界面状态,并在下载成功后把对应条目的安装状态就地更新, + * 避免为了一个"已下载"徽标再打一次清单请求。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { InitLocalProjectResult } from '../../app/types'; +import { + collectGameTemplateRuntimes, + collectGameTemplateTags, + EMPTY_TEMPLATE_LIBRARY_FILTERS, + filterGameTemplates, + type GameTemplateEntry, + type GameTemplateLibrarySnapshot, + type InstalledGameTemplate, + isTemplateLibraryFiltersEmpty, + needsTemplateDownload, + type TemplateLibraryFilters, + toggleGameTemplateTag, +} from './templateLibraryModel'; + +export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error'; +export type TemplateLibraryBusyKind = 'download' | 'create'; + +type UseTemplateLibraryOptions = { + /** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */ + onProjectCreated: (result: InitLocalProjectResult) => Promise | void; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function useTemplateLibrary({ + onProjectCreated, +}: UseTemplateLibraryOptions) { + const [snapshot, setSnapshot] = useState( + null, + ); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [filters, setFilters] = useState( + EMPTY_TEMPLATE_LIBRARY_FILTERS, + ); + const [busyTemplateId, setBusyTemplateId] = useState(null); + const [busyKind, setBusyKind] = useState( + null, + ); + const loadingRef = useRef(false); + + const refresh = useCallback(async () => { + if (loadingRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('error'); + setError('需要在陶泥儿客户端内运行'); + return; + } + loadingRef.current = true; + setStatus('loading'); + setError(''); + try { + const next = await invoke( + 'fetch_game_template_library', + ); + setSnapshot(next); + setStatus('ready'); + setNotice( + next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '', + ); + } catch (nextError) { + setStatus('error'); + setError(errorMessage(nextError)); + } finally { + loadingRef.current = false; + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const downloadTemplate = useCallback(async (template: GameTemplateEntry) => { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + setBusyTemplateId(template.id); + setBusyKind('download'); + setError(''); + try { + const installed = await invoke( + 'download_game_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + }, + ); + setSnapshot((current) => + current + ? { + ...current, + templates: current.templates.map((entry) => + entry.id === template.id + ? { + ...entry, + installed: true, + installedVersion: installed.templateVersion, + installedAtMillis: installed.installedAtMillis, + } + : entry, + ), + } + : current, + ); + setNotice(`已下载模板「${template.title}」`); + return installed; + } catch (nextError) { + setError(errorMessage(nextError)); + throw nextError; + } finally { + setBusyTemplateId(null); + setBusyKind(null); + } + }, []); + + const createProjectFromTemplate = useCallback( + async (template: GameTemplateEntry) => { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + try { + if (needsTemplateDownload(template)) { + await downloadTemplate(template); + } + setBusyTemplateId(template.id); + setBusyKind('create'); + setError(''); + setNotice(`正在用模板「${template.title}」创建项目`); + const result = await invoke( + 'create_automatic_local_game_project_from_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + name: null, + planning: false, + }, + ); + await onProjectCreated(result); + setNotice(`已用模板「${template.title}」创建项目`); + return result; + } catch (nextError) { + setError(errorMessage(nextError)); + throw nextError; + } finally { + setBusyTemplateId(null); + setBusyKind(null); + } + }, + [downloadTemplate, onProjectCreated], + ); + + const templates = useMemo( + () => snapshot?.templates ?? [], + [snapshot?.templates], + ); + const visibleTemplates = useMemo( + () => filterGameTemplates(templates, filters), + [templates, filters], + ); + const tagOptions = useMemo( + () => collectGameTemplateTags(templates), + [templates], + ); + const runtimeOptions = useMemo( + () => collectGameTemplateRuntimes(templates), + [templates], + ); + const installedCount = useMemo( + () => templates.filter((template) => template.installed).length, + [templates], + ); + const filtersActive = !isTemplateLibraryFiltersEmpty(filters); + + const setQuery = useCallback((query: string) => { + setFilters((current) => ({ ...current, query })); + }, []); + + const selectRuntime = useCallback((runtime: string) => { + setFilters((current) => ({ + ...current, + runtime: current.runtime === runtime ? '' : runtime, + })); + }, []); + + const toggleTag = useCallback((tag: string) => { + setFilters((current) => toggleGameTemplateTag(current, tag)); + }, []); + + const setInstalledOnly = useCallback((installedOnly: boolean) => { + setFilters((current) => ({ ...current, installedOnly })); + }, []); + + const clearFilters = useCallback(() => { + setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS); + }, []); + + return { + snapshot, + status, + error, + notice, + templates, + visibleTemplates, + tagOptions, + runtimeOptions, + installedCount, + filters, + filtersActive, + setQuery, + selectRuntime, + toggleTag, + setInstalledOnly, + clearFilters, + busyTemplateId, + busyKind, + refresh, + downloadTemplate, + createProjectFromTemplate, + clearNotice: useCallback(() => setNotice(''), []), + }; +} + +export type TemplateLibraryController = ReturnType; diff --git a/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx b/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx deleted file mode 100644 index dc15824e9..000000000 --- a/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; - -// TODO: 后续改为由服务器下发灵感资源;届时保留此组件的展示与预览交互,移除本地目录扫描。 -const INSPIRATION_IMAGES = Object.entries( - import.meta.glob('./assets/inspiration/*.{webp,png,jpg,jpeg}', { - eager: true, - import: 'default', - query: '?url', - }), -) - .sort(([left], [right]) => - left.localeCompare(right, undefined, { numeric: true }), - ) - .map(([, image]) => image as string); - -export default function InspirationGallery() { - const [selectedImage, setSelectedImage] = useState(null); - - useEffect(() => { - if (!selectedImage) { - return; - } - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setSelectedImage(null); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [selectedImage]); - - return ( - <> -
- {INSPIRATION_IMAGES.map((image, index) => ( - - ))} -
- - {selectedImage - ? createPortal( -
setSelectedImage(null)} - > - 放大的灵感图片 event.stopPropagation()} - /> -
, - document.body, - ) - : null} - - ); -} diff --git a/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx b/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx new file mode 100644 index 000000000..56c4b1be6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx @@ -0,0 +1,96 @@ +import { BadgeCheck, Loader2, Package } from 'lucide-react'; + +import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; +import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel'; + +type TemplateRecommendationsProps = { + templates: readonly GameTemplateEntry[]; + loading: boolean; + error: string; + onOpenLibrary: () => void; +}; + +const RECOMMENDATION_LIMIT = 6; + +/** + * 首页模板推荐:只做展示与跳转,下载与建项目都在模板库页面里完成, + * 避免首页的卡片点击直接产生项目副作用。 + */ +export default function TemplateRecommendations({ + templates, + loading, + error, + onOpenLibrary, +}: TemplateRecommendationsProps) { + if (loading && templates.length === 0) { + return ( +
+
+ ); + } + + if (templates.length === 0) { + return ( +
+
+ ); + } + + return ( +
+ {templates.slice(0, RECOMMENDATION_LIMIT).map((template) => ( + + ))} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp deleted file mode 100644 index 25821e804..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp deleted file mode 100644 index ce26591db..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp deleted file mode 100644 index 90287a674..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp deleted file mode 100644 index 8fa04357a..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp deleted file mode 100644 index cc657f1ef..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp deleted file mode 100644 index bf922e8d3..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp deleted file mode 100644 index 67048cfb9..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp deleted file mode 100644 index 13f642612..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp deleted file mode 100644 index d98a01ec7..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp deleted file mode 100644 index 4ed427e84..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp deleted file mode 100644 index b147b8ff7..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp deleted file mode 100644 index 084ed36bb..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp deleted file mode 100644 index 7da22fa4f..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp deleted file mode 100644 index 88dcc70cb..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp deleted file mode 100644 index 13315c560..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp deleted file mode 100644 index 4af52bbda..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp deleted file mode 100644 index 87c1ebe09..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp deleted file mode 100644 index cc4bcb6c5..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp deleted file mode 100644 index 413fff72b..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp deleted file mode 100644 index b55594bba..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp deleted file mode 100644 index 31db46017..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp deleted file mode 100644 index 5d2e1fa4e..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp deleted file mode 100644 index f34cd2398..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp deleted file mode 100644 index b23f5e8d1..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp deleted file mode 100644 index e008db1fc..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 16236d779..ed160d88f 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -14,13 +14,14 @@ import { useRef, useState } from 'react'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import type { ProjectStartMode } from '../../app/types'; import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect'; +import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, richTextToPrompt, } from './components/RichInputArea/richTextToPrompt'; import { resolveHomeStartMode } from './homeStartMode'; -import InspirationGallery from './InspirationGallery'; +import TemplateRecommendations from './TemplateRecommendations'; import { type HomeCreationType, type HomeDraft, @@ -113,6 +114,11 @@ type HomeViewProps = { onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; + /** 模板库推荐位:清单来自 Rust 侧模板库,首页只负责展示与跳转。 */ + templateRecommendations: readonly GameTemplateEntry[]; + templateLibraryLoading: boolean; + templateLibraryError: string; + onTemplateLibraryOpen: () => void; }; export default function HomeView({ @@ -126,6 +132,10 @@ export default function HomeView({ onProjectsOpen, onProjectOpen, onProjectPick, + templateRecommendations, + templateLibraryLoading, + templateLibraryError, + onTemplateLibraryOpen, }: HomeViewProps) { const homeCreationType = useLauncherHomeDraftStore( (state) => state.creationType, @@ -421,12 +431,12 @@ export default function HomeView({

- 灵感推荐 + 模板库

+
- +
); diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 9974817f5..4464661ce 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -3,6 +3,7 @@ import { CircleHelp, FolderKanban, Home, + LayoutTemplate, Plus, Settings, User, @@ -30,6 +31,7 @@ export type LauncherView = | 'guide' | 'contact' | 'news' + | 'template-library' | 'project-development'; type SidebarUserInfo = { @@ -284,6 +286,19 @@ export function Sidebar({ >