diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index ce2317900..fcaf78727 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -57,6 +57,7 @@ "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "vite": "^6.2.0", @@ -70,6 +71,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-window": "^1.8.8", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index b5cb1f2d2..4dd833d85 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -6,6 +6,9 @@ publish = false [features] default = [] +# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后 +# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。 +template-library-fixtures = [] cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] 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 6a57de8e2..cdfafff99 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..ee2cea8dc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -0,0 +1,1272 @@ +//! 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), + }) +} + +/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。 +/// +/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`); +/// 未开启时是恒等透传,正式构建里没有任何注入分支。 +fn apply_template_library_fixtures(entries: Vec) -> Vec { + #[cfg(feature = "template-library-fixtures")] + { + return fixtures::pad_synthetic_templates(entries, fixtures::synthetic_template_count()); + } + #[cfg(not(feature = "template-library-fixtures"))] + { + entries + } +} + +/// 本地假数据注入实现:只在 `template-library-fixtures` feature(或测试构建)下编译。 +/// +/// 位置刻意放在 Rust 侧:模板库的清单校验、安装状态与建项目都在这一侧,TS 只消费快照做渲染; +/// 在这里注入才能压到与真实一致的整条链路。正式构建不含该模块,因此不存在误触发路径。 +#[cfg(any(test, feature = "template-library-fixtures"))] +pub(crate) mod fixtures { + use super::*; + + pub(crate) const SYNTHETIC_COUNT_ENV: &str = "AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT"; + pub(crate) const DEFAULT_SYNTHETIC_COUNT: usize = 1_000; + const MAX_SYNTHETIC_COUNT: usize = 20_000; + + /// 假数据条数:环境变量优先,缺省 1000;0 表示不注入。 + pub(crate) fn synthetic_template_count() -> usize { + parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref()) + } + + fn parse_synthetic_count(raw: Option<&str>) -> usize { + raw.and_then(|value| value.trim().parse::().ok()) + .unwrap_or(DEFAULT_SYNTHETIC_COUNT) + .min(MAX_SYNTHETIC_COUNT) + } + + /// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。 + pub(crate) fn pad_synthetic_templates( + entries: Vec, + target: usize, + ) -> Vec { + if target <= entries.len() || entries.is_empty() { + return entries; + } + let base = entries.clone(); + let mut padded = entries; + let mut index = padded.len(); + while padded.len() < target { + let source = &base[index % base.len()]; + let installed = index % 3 == 0; + let mut next = source.clone(); + next.id = format!("{}-{:04}", source.id, index); + next.title = format!("{} · 假数据 {index:04}", source.title); + let mut tags = source.tags.clone(); + tags.push(format!("批次-{:02}", index % 20)); + next.tags = tags; + next.cover_url = format!("{}?synthetic={index}", source.cover_url); + next.installed = installed; + next.installed_version = installed.then(|| source.template_version.clone()); + next.installed_at_millis = installed.then(|| now_millis()); + padded.push(next); + index += 1; + } + padded + } + + #[cfg(test)] + mod tests { + use super::*; + + fn base_entry(id: &str, tag: &str) -> GameTemplateEntry { + GameTemplateEntry { + id: id.to_string(), + title: format!("模板 {id}"), + summary: "假数据基础条目".to_string(), + tags: vec![tag.to_string()], + runtime: "html".to_string(), + engine: "phaser".to_string(), + engine_version: "4.2.1".to_string(), + template_version: "0.1.0".to_string(), + updated_at: "2026-09-17T00:00:00Z".to_string(), + entry: "game/index.html".to_string(), + zip_url: format!("https://oss.example/templates/v1/{id}/template.zip"), + zip_size_bytes: 1024, + zip_sha256: "a".repeat(64), + cover_url: format!("https://oss.example/templates/v1/{id}/cover.svg"), + cover_width: 960, + cover_height: 540, + installed: false, + installed_version: None, + installed_at_millis: None, + } + } + + #[test] + fn parses_synthetic_count_from_env_value() { + assert_eq!(parse_synthetic_count(None), DEFAULT_SYNTHETIC_COUNT); + assert_eq!(parse_synthetic_count(Some(" 250 ")), 250); + assert_eq!(parse_synthetic_count(Some("0")), 0); + assert_eq!( + parse_synthetic_count(Some("not-a-number")), + DEFAULT_SYNTHETIC_COUNT + ); + assert_eq!(parse_synthetic_count(Some("999999")), MAX_SYNTHETIC_COUNT); + } + + #[test] + fn pads_entries_with_unique_identity_and_mixed_install_state() { + let base = vec![ + base_entry("blank-web", "空白"), + base_entry("blank-2d", "2d"), + ]; + let padded = pad_synthetic_templates(base.clone(), 9); + assert_eq!(padded.len(), 9); + // 真实条目保持原样排在最前。 + assert_eq!(padded[0].id, "blank-web"); + assert_eq!(padded[1].id, "blank-2d"); + let ids = padded + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + assert_eq!(ids.len(), 9, "假数据 id 必须唯一"); + let covers = padded + .iter() + .map(|entry| entry.cover_url.clone()) + .collect::>(); + assert_eq!(covers.len(), 9, "假数据封面地址必须唯一"); + assert!(padded.iter().any(|entry| entry.installed)); + assert!(padded.iter().any(|entry| !entry.installed)); + assert!(padded + .iter() + .skip(2) + .all(|entry| entry.tags.iter().any(|tag| tag.starts_with("批次-")))); + } + + #[test] + fn padding_is_a_no_op_without_room_to_fill() { + let base = vec![base_entry("blank-web", "空白")]; + assert_eq!(pad_synthetic_templates(base.clone(), 1).len(), 1); + assert_eq!(pad_synthetic_templates(base.clone(), 0).len(), 1); + assert!(pad_synthetic_templates(Vec::new(), 10).is_empty()); + } + } +} + +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::, _>>()?; + let entries = apply_template_library_fixtures(entries); + 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![ + "blank-2d-canvas", + "blank-3d-scene", + "blank-web", + "phaser-2d-starter", + "threejs-3d-starter", + ] + ); + let first = templates + .iter() + .find(|entry| entry.id == "phaser-2d-starter") + .expect("phaser 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.svg") + ); + 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}"); + } + + /// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。 + #[cfg(not(feature = "template-library-fixtures"))] + #[test] + fn fixtures_are_inert_without_the_feature() { + let index_body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (_, templates) = + parse_game_template_library_index(index_body).expect("parse fixture index"); + let entries = templates + .iter() + .map(|summary| to_entry(summary, None).expect("entry")) + .collect::>(); + let original = entries.len(); + let applied = apply_template_library_fixtures(entries); + assert_eq!(applied.len(), original, "默认构建不应注入假数据"); + } + + /// 开启 feature 后同一次调用必须补齐到配置条数。 + #[cfg(feature = "template-library-fixtures")] + #[test] + fn fixtures_expand_entries_when_the_feature_is_enabled() { + let index_body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (_, templates) = + parse_game_template_library_index(index_body).expect("parse fixture index"); + let entries = templates + .iter() + .map(|summary| to_entry(summary, None).expect("entry")) + .collect::>(); + let applied = apply_template_library_fixtures(entries); + assert_eq!(applied.len(), fixtures::synthetic_template_count()); + assert!(applied.len() > templates.len()); + } + + /// 可选的真连检查:`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}/"))); + } + } + + /// 可选的真连检查:下载线上模板包并安装到临时目录,证明"清单 → 下载 → 摘要校验 → 解压"整条链路可用。 + #[tokio::test] + #[ignore = "需要网络:下载并安装线上模板包"] + async fn downloads_and_installs_a_live_template() { + let cache_root = tempfile::tempdir().expect("temp dir"); + 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 (_, templates) = parse_game_template_library_index(&body).expect("parse live index"); + let entry = templates + .iter() + .find(|template| template.id == "blank-web") + .expect("线上应存在 blank-web 模板"); + let bytes = fetch_limited_bytes( + &client, + &template_object_url(&entry.zip_key).expect("trusted zip url"), + TEMPLATE_ARCHIVE_MAX_BYTES, + ) + .await + .expect("download live template"); + let record = install_template_archive(cache_root.path(), entry, &bytes) + .expect("install live template"); + assert!(record.file_count >= 5, "模板文件数 {}", record.file_count); + let project_root = Path::new(&record.project_dir); + assert!(project_root.join("game/index.html").is_file()); + assert!(project_root.join("game/main.js").is_file()); + assert!(project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).is_file()); + + // 同一条链路继续建项目:线上模板 → 本机安装 → 新项目目录。 + let projects_root = unique_projects_root(); + let created = create_project_from_installed_template_at( + &projects_root, + project_root, + Some("线上模板项目"), + false, + ) + .expect("create project from live template"); + assert_eq!(created.manifest.name, "线上模板项目"); + let created_root = Path::new(&created.project_path); + assert!(created_root.join("game/index.html").is_file()); + assert!(created_root.join("game/main.js").is_file()); + assert!(created_root.join(".agent/manifest.json").is_file()); + fs::remove_dir_all(&projects_root).ok(); + } +} 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..7cf1b85a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json @@ -0,0 +1,133 @@ +{ + "schemaVersion": "agc-template-library.v1", + "library": "agc-game-templates", + "libraryVersion": 1, + "updatedAt": "2026-09-17T03:22:43Z", + "templates": [ + { + "id": "blank-2d-canvas", + "title": "空白二维画布工程", + "summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。", + "tags": [ + "空白", + "起步工程", + "2d", + "canvas" + ], + "runtime": "html", + "engine": "canvas", + "engineVersion": "", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-2d-canvas/template.zip", + "zipSizeBytes": 1534, + "zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134", + "coverKey": "templates/v1/blank-2d-canvas/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192", + "metadataKey": "templates/v1/blank-2d-canvas/template.json" + }, + { + "id": "blank-3d-scene", + "title": "空白三维场景工程", + "summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。", + "tags": [ + "空白", + "起步工程", + "3d", + "three.js" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-3d-scene/template.zip", + "zipSizeBytes": 1644, + "zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892", + "coverKey": "templates/v1/blank-3d-scene/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7", + "metadataKey": "templates/v1/blank-3d-scene/template.json" + }, + { + "id": "blank-web", + "title": "空白网页工程", + "summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。", + "tags": [ + "空白", + "起步工程", + "网页", + "原生" + ], + "runtime": "html", + "engine": "none", + "engineVersion": "", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-web/template.zip", + "zipSizeBytes": 1212, + "zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e", + "coverKey": "templates/v1/blank-web/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a", + "metadataKey": "templates/v1/blank-web/template.json" + }, + { + "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:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/phaser-2d-starter/template.zip", + "zipSizeBytes": 8770, + "zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce", + "coverKey": "templates/v1/phaser-2d-starter/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96", + "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:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/threejs-3d-starter/template.zip", + "zipSizeBytes": 1697, + "zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c", + "coverKey": "templates/v1/threejs-3d-starter/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb", + "metadataKey": "templates/v1/threejs-3d-starter/template.json" + } + ] +} diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 70f9eb496..b82edbc3f 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -1,6 +1,12 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { Copy, Minus, Square, X } from 'lucide-react'; -import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel'; @@ -49,14 +55,21 @@ export function WindowChrome({ children }: WindowChromeProps) { setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE); }, []); - const contextValue: WindowChromeContextValue = { - isWindowChrome: true, - title, - setTitle, - walletSlot, - activeProjectRuns, - setActiveProjectRuns, - }; + /** + * context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏 + * 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。 + */ + const contextValue = useMemo( + () => ({ + isWindowChrome: true, + title, + setTitle, + walletSlot, + activeProjectRuns, + setActiveProjectRuns, + }), + [title, setTitle, walletSlot, activeProjectRuns], + ); const [isMaximized, setIsMaximized] = useState(false); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index f859284e1..a9d4c478e 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -38,6 +38,14 @@ export function useDirectActiveTurns({ const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); + /** + * 上一次成功读取到的快照签名。 + * + * 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会 + * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 + * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 + */ + const lastSnapshotSignatureRef = useRef(''); const retryTimerRef = useRef(null); useEffect(() => { @@ -72,7 +80,12 @@ export function useDirectActiveTurns({ if (!mountedRef.current) { return; } - setActiveTurns(Array.isArray(turns) ? turns : []); + const nextTurns = Array.isArray(turns) ? turns : []; + const nextSignature = JSON.stringify(nextTurns); + if (nextSignature !== lastSnapshotSignatureRef.current) { + lastSnapshotSignatureRef.current = nextSignature; + setActiveTurns(nextTurns); + } setSnapshotReadFailed(false); inFlightRef.current = null; return; @@ -99,8 +112,10 @@ export function useDirectActiveTurns({ useEffect(() => { if (!enabled || !invoke) { - setActiveTurns([]); - setSnapshotReadFailed(false); + lastSnapshotSignatureRef.current = ''; + // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 + setActiveTurns((current) => (current.length === 0 ? current : [])); + setSnapshotReadFailed((current) => (current ? false : current)); return; } void refreshActiveTurns(); 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 d7c064561..85107f7b9 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 @@ -32,8 +32,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, @@ -90,6 +92,11 @@ export function WorkspaceLauncherShell({ setAgentChatProjectPath: developerAgent.setAgentChatProjectPath, rememberRecentWorkspace, }); + const templateLibrary = useTemplateLibrary({ + onProjectCreated: async (result) => { + await homeProject.enterCreatedTemplateProject(result); + }, + }); const { projectPath, setProjectPath, @@ -222,17 +229,29 @@ export function WorkspaceLauncherShell({ [setProjectPath], ); + /** + * 项目卡片面板发布给窗口标题栏的回调必须走 ref。 + * + * `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份), + * 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成 + * 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」 + * 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。 + * 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。 + */ + const openActiveProjectRef = useRef(openActiveProject); + openActiveProjectRef.current = openActiveProject; + useEffect(() => { setActiveProjectRuns({ activeTurns, currentProjectPath: currentProjectContext?.projectPath ?? null, readFailed: snapshotReadFailed, - onOpenProject: openActiveProject, + onOpenProject: (projectPath: string) => + openActiveProjectRef.current(projectPath), }); }, [ activeTurns, currentProjectContext?.projectPath, - openActiveProject, setActiveProjectRuns, snapshotReadFailed, ]); @@ -580,6 +599,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' ? ( > { + if (columnCount <= 0) { + return []; + } + const rows: Array> = []; + for (let index = 0; index < templates.length; index += columnCount) { + const row: Array = []; + for (let column = 0; column < columnCount; column += 1) { + row.push(templates[index + column] ?? null); + } + rows.push(row); + } + return rows; +} + +/** 虚拟列表的稳定 key:行内槽位固定,避免筛选后复用错卡片。 */ +export function templateGridItemKey( + rowIndex: number, + columnIndex: number, +): string { + return `template-cell-${rowIndex}-${columnIndex}`; +} 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({ >