diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8bc67b5e8..94331c747 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -721,6 +721,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4a15aa5b..b44052469 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] } indexmap = { version = "2", features = ["serde"] } rust_decimal = "1.33" uuid = { version = "1.11", features = ["v4"] } +sha2 = "0.10" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 85dfb3adc..f1c11c2d7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ mod proxy; mod settings; pub mod skill; mod stream_check; +mod template; mod usage; pub use config::*; @@ -30,4 +31,5 @@ pub use proxy::*; pub use settings::*; pub use skill::*; pub use stream_check::*; +pub use template::*; pub use usage::*; diff --git a/src-tauri/src/commands/template.rs b/src-tauri/src/commands/template.rs new file mode 100644 index 000000000..76de348a0 --- /dev/null +++ b/src-tauri/src/commands/template.rs @@ -0,0 +1,278 @@ +use tauri::State; + +use crate::database::lock_conn; +use crate::error::AppError; +use crate::services::{ + BatchInstallResult, ComponentDetail, InstalledComponent, PaginatedResult, TemplateComponent, + TemplateRepo, TemplateService, +}; +use crate::store::AppState; + +/// 刷新模板索引 +#[tauri::command] +pub async fn refresh_template_index(state: State<'_, AppState>) -> Result<(), String> { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + // 使用 spawn_blocking 在后台线程中执行数据库操作 + tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .refresh_index(&conn) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))??; + + Ok(()) +} + +/// 获取模板组件列表 +#[tauri::command] +pub fn list_template_components( + state: State<'_, AppState>, + component_type: Option, + category: Option, + search: Option, + page: u32, + page_size: u32, + app_type: Option, +) -> Result, AppError> { + let (mut components, total) = state.db.list_components( + component_type.as_deref(), + category.as_deref(), + search.as_deref(), + page, + page_size, + )?; + + // 填充 installed 字段 + if let Some(app) = &app_type { + let installed_ids = state.db.get_installed_component_ids(app)?; + for component in &mut components { + if let Some(id) = component.id { + component.installed = installed_ids.contains(&id); + } + } + } + + Ok(PaginatedResult { + items: components, + total: total as i64, + page, + page_size, + }) +} + +/// 获取组件详情 +#[tauri::command] +pub async fn get_template_component( + state: State<'_, AppState>, + id: i64, +) -> Result { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + let detail = tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .get_component(&conn, id) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))??; + + Ok(detail) +} + +/// 安装组件 +#[tauri::command] +pub async fn install_template_component( + state: State<'_, AppState>, + id: i64, + app_type: String, +) -> Result<(), String> { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .install_component(&conn, id, &app_type) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))??; + + Ok(()) +} + +/// 卸载组件 +#[tauri::command] +pub fn uninstall_template_component( + state: State<'_, AppState>, + id: i64, + app_type: String, +) -> Result<(), AppError> { + let service = TemplateService::new().map_err(|e| AppError::Config(e.to_string()))?; + let conn = lock_conn!(state.db.conn); + + service + .uninstall_component(&conn, id, &app_type) + .map_err(|e| AppError::Config(e.to_string()))?; + Ok(()) +} + +/// 批量安装组件 +#[tauri::command] +pub async fn batch_install_template_components( + state: State<'_, AppState>, + ids: Vec, + app_type: String, +) -> Result { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + let result = tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .batch_install(&conn, ids, &app_type) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))??; + + Ok(result) +} + +/// 获取模板仓库列表 +#[tauri::command] +pub fn list_template_repos(state: State<'_, AppState>) -> Result, AppError> { + state.db.list_repos() +} + +/// 添加模板仓库 +#[tauri::command] +pub fn add_template_repo( + state: State<'_, AppState>, + owner: String, + name: String, + branch: String, +) -> Result { + let repo = TemplateRepo::new(owner, name, branch); + state.db.insert_repo(&repo) +} + +/// 删除模板仓库 +#[tauri::command] +pub fn remove_template_repo(state: State<'_, AppState>, id: i64) -> Result<(), AppError> { + state.db.delete_repo(id) +} + +/// 切换仓库启用状态 +#[tauri::command] +pub fn toggle_template_repo( + state: State<'_, AppState>, + id: i64, + enabled: bool, +) -> Result<(), AppError> { + state.db.toggle_repo_enabled(id, enabled) +} + +/// 获取组件分类列表 +#[tauri::command] +pub fn list_template_categories( + state: State<'_, AppState>, + component_type: Option, +) -> Result, AppError> { + let conn = lock_conn!(state.db.conn); + + // 构建查询语句 + let sql = if let Some(ct) = component_type { + format!( + "SELECT DISTINCT category FROM template_components WHERE component_type = '{ct}' AND category IS NOT NULL ORDER BY category" + ) + } else { + "SELECT DISTINCT category FROM template_components WHERE category IS NOT NULL ORDER BY category".to_string() + }; + + let mut stmt = conn.prepare(&sql)?; + let categories = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + + Ok(categories) +} + +/// 获取已安装组件列表 +#[tauri::command] +pub fn list_installed_components( + state: State<'_, AppState>, + app_type: Option, + component_type: Option, +) -> Result, AppError> { + state + .db + .list_installed_components(app_type.as_deref(), component_type.as_deref()) +} + +/// 预览组件内容 +#[tauri::command] +pub async fn preview_component_content( + state: State<'_, AppState>, + id: i64, +) -> Result { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .preview_content(&conn, id) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))? +} + +/// 获取市场组合列表 +#[tauri::command] +pub async fn list_marketplace_bundles( + state: State<'_, AppState>, +) -> Result, String> { + let service = TemplateService::new().map_err(|e| e.to_string())?; + let db = state.db.clone(); + + tokio::task::spawn_blocking(move || { + let conn = lock_conn!(db.conn); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + service + .fetch_marketplace_bundles(&conn) + .await + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| format!("任务执行失败: {e}"))? +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index c759c2a86..5e2db090e 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -10,6 +10,7 @@ pub mod proxy; pub mod settings; pub mod skills; pub mod stream_check; +pub mod template; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 diff --git a/src-tauri/src/database/dao/template.rs b/src-tauri/src/database/dao/template.rs new file mode 100644 index 000000000..0e96b6b2a --- /dev/null +++ b/src-tauri/src/database/dao/template.rs @@ -0,0 +1,595 @@ +//! Template 数据访问对象 +//! +//! 提供 Template Repos、Template Components 和 Installed Components 的 CRUD 操作。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::services::template::{ + ComponentType, InstalledComponent, TemplateComponent, TemplateRepo, +}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, OptionalExtension}; + +impl Database { + // ==================== TemplateRepo 相关 ==================== + + /// 插入模板仓库 + pub fn insert_repo(&self, repo: &TemplateRepo) -> Result { + let conn = lock_conn!(self.conn); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO template_repos (owner, name, branch, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![repo.owner, repo.name, repo.branch, repo.enabled, now, now], + ) + .map_err(|e| AppError::Database(format!("插入模板仓库失败: {e}")))?; + + Ok(conn.last_insert_rowid()) + } + + /// 获取单个模板仓库 + pub fn get_repo(&self, id: i64) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, owner, name, branch, enabled, created_at, updated_at + FROM template_repos + WHERE id = ?1", + ) + .map_err(|e| AppError::Database(format!("准备查询模板仓库失败: {e}")))?; + + let repo = stmt + .query_row(params![id], |row| { + Ok(TemplateRepo { + id: Some(row.get(0)?), + owner: row.get(1)?, + name: row.get(2)?, + branch: row.get(3)?, + enabled: row.get(4)?, + created_at: row + .get::<_, String>(5) + .ok() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + updated_at: row + .get::<_, String>(6) + .ok() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + }) + }) + .optional() + .map_err(|e| AppError::Database(format!("查询模板仓库失败: {e}")))?; + + Ok(repo) + } + + /// 获取所有模板仓库 + pub fn list_repos(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, owner, name, branch, enabled, created_at, updated_at + FROM template_repos + ORDER BY created_at DESC", + ) + .map_err(|e| AppError::Database(format!("准备查询模板仓库列表失败: {e}")))?; + + let repo_iter = stmt + .query_map([], |row| { + Ok(TemplateRepo { + id: Some(row.get(0)?), + owner: row.get(1)?, + name: row.get(2)?, + branch: row.get(3)?, + enabled: row.get(4)?, + created_at: row + .get::<_, String>(5) + .ok() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + updated_at: row + .get::<_, String>(6) + .ok() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + }) + }) + .map_err(|e| AppError::Database(format!("查询模板仓库列表失败: {e}")))?; + + let mut repos = Vec::new(); + for repo_res in repo_iter { + repos.push(repo_res.map_err(|e| AppError::Database(format!("解析模板仓库失败: {e}")))?); + } + + Ok(repos) + } + + /// 更新模板仓库 + pub fn update_repo(&self, repo: &TemplateRepo) -> Result<(), AppError> { + let repo_id = repo + .id + .ok_or_else(|| AppError::Database("仓库 ID 不能为空".to_string()))?; + let conn = lock_conn!(self.conn); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE template_repos + SET owner = ?1, name = ?2, branch = ?3, enabled = ?4, updated_at = ?5 + WHERE id = ?6", + params![ + repo.owner, + repo.name, + repo.branch, + repo.enabled, + now, + repo_id + ], + ) + .map_err(|e| AppError::Database(format!("更新模板仓库失败: {e}")))?; + + Ok(()) + } + + /// 删除模板仓库 + pub fn delete_repo(&self, id: i64) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute("DELETE FROM template_repos WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(format!("删除模板仓库失败: {e}")))?; + + Ok(()) + } + + /// 切换仓库启用状态 + pub fn toggle_repo_enabled(&self, id: i64, enabled: bool) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE template_repos SET enabled = ?1, updated_at = ?2 WHERE id = ?3", + params![enabled, now, id], + ) + .map_err(|e| AppError::Database(format!("切换仓库启用状态失败: {e}")))?; + + Ok(()) + } + + // ==================== TemplateComponent 相关 ==================== + + /// 插入模板组件 + pub fn insert_component(&self, component: &TemplateComponent) -> Result { + let conn = lock_conn!(self.conn); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO template_components + (repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + component.repo_id, + component.component_type.as_str(), + component.category, + component.name, + component.path, + component.description, + component.content_hash, + now, + now + ], + ) + .map_err(|e| AppError::Database(format!("插入模板组件失败: {e}")))?; + + Ok(conn.last_insert_rowid()) + } + + /// 获取单个模板组件 + pub fn get_component(&self, id: i64) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + WHERE id = ?1", + ) + .map_err(|e| AppError::Database(format!("准备查询模板组件失败: {e}")))?; + + let component = stmt + .query_row(params![id], |row| { + let component_type_str: String = row.get(2)?; + let component_type = ComponentType::from_str(&component_type_str) + .ok_or_else(|| rusqlite::Error::InvalidQuery)?; + + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type, + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, // 需要单独查询 + }) + }) + .optional() + .map_err(|e| AppError::Database(format!("查询模板组件失败: {e}")))?; + + Ok(component) + } + + /// 获取组件列表(支持过滤和分页) + pub fn list_components( + &self, + component_type: Option<&str>, + category: Option<&str>, + search: Option<&str>, + page: u32, + page_size: u32, + ) -> Result<(Vec, u32), AppError> { + let conn = lock_conn!(self.conn); + + // 构建 WHERE 子句 + let mut where_clauses = Vec::new(); + let mut params_vec: Vec> = Vec::new(); + + if let Some(ct) = component_type { + where_clauses.push("component_type = ?"); + params_vec.push(Box::new(ct.to_string())); + } + + if let Some(cat) = category { + where_clauses.push("category = ?"); + params_vec.push(Box::new(cat.to_string())); + } + + if let Some(s) = search { + where_clauses.push("(name LIKE ? OR description LIKE ?)"); + let pattern = format!("%{s}%"); + params_vec.push(Box::new(pattern.clone())); + params_vec.push(Box::new(pattern)); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + // 查询总数 + let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}"); + + let total: u32 = { + let mut stmt = conn + .prepare(&count_sql) + .map_err(|e| AppError::Database(format!("准备统计组件数量失败: {e}")))?; + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + stmt.query_row(¶ms_refs[..], |row| row.get(0)) + .map_err(|e| AppError::Database(format!("统计组件数量失败: {e}")))? + }; + + // 查询数据 + let offset = (page.saturating_sub(1)) * page_size; + let query_sql = format!( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + {where_sql} + ORDER BY name ASC + LIMIT ? OFFSET ?" + ); + + let mut stmt = conn + .prepare(&query_sql) + .map_err(|e| AppError::Database(format!("准备查询组件列表失败: {e}")))?; + + params_vec.push(Box::new(page_size)); + params_vec.push(Box::new(offset)); + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + let component_iter = stmt + .query_map(¶ms_refs[..], |row| { + let component_type_str: String = row.get(2)?; + let component_type = ComponentType::from_str(&component_type_str) + .ok_or_else(|| rusqlite::Error::InvalidQuery)?; + + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type, + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, // 需要单独查询 + }) + }) + .map_err(|e| AppError::Database(format!("查询组件列表失败: {e}")))?; + + let mut components = Vec::new(); + for component_res in component_iter { + components + .push(component_res.map_err(|e| AppError::Database(format!("解析组件失败: {e}")))?); + } + + Ok((components, total)) + } + + /// 删除仓库的所有组件 + pub fn delete_components_by_repo(&self, repo_id: i64) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM template_components WHERE repo_id = ?1", + params![repo_id], + ) + .map_err(|e| AppError::Database(format!("删除仓库组件失败: {e}")))?; + + Ok(()) + } + + /// Upsert 模板组件(根据 repo_id + component_type + path 判断是否已存在) + pub fn upsert_component(&self, component: &TemplateComponent) -> Result { + let conn = lock_conn!(self.conn); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO template_components + (repo_id, component_type, category, name, path, description, content_hash, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(repo_id, component_type, path) DO UPDATE SET + category = excluded.category, + name = excluded.name, + description = excluded.description, + content_hash = excluded.content_hash, + updated_at = excluded.updated_at", + params![ + component.repo_id, + component.component_type.as_str(), + component.category, + component.name, + component.path, + component.description, + component.content_hash, + now, + now + ], + ) + .map_err(|e| AppError::Database(format!("Upsert 模板组件失败: {e}")))?; + + Ok(conn.last_insert_rowid()) + } + + // ==================== InstalledComponent 相关 ==================== + + /// 插入已安装组件 + pub fn insert_installed(&self, installed: &InstalledComponent) -> Result { + let conn = lock_conn!(self.conn); + let installed_at = installed.installed_at.to_rfc3339(); + + conn.execute( + "INSERT INTO installed_components + (component_id, component_type, name, path, app_type, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + installed.component_id, + installed.component_type.as_str(), + installed.name, + installed.path, + installed.app_type, + installed_at + ], + ) + .map_err(|e| AppError::Database(format!("插入已安装组件失败: {e}")))?; + + Ok(conn.last_insert_rowid()) + } + + /// 删除已安装组件 + pub fn delete_installed( + &self, + component_type: &str, + path: &str, + app_type: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM installed_components + WHERE component_type = ?1 AND path = ?2 AND app_type = ?3", + params![component_type, path, app_type], + ) + .map_err(|e| AppError::Database(format!("删除已安装组件失败: {e}")))?; + + Ok(()) + } + + /// 获取已安装组件列表 + pub fn list_installed( + &self, + app_type: Option<&str>, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let (sql, params_vec): (String, Vec>) = if let Some(at) = app_type + { + ( + "SELECT id, component_id, component_type, name, path, app_type, installed_at + FROM installed_components + WHERE app_type = ? + ORDER BY installed_at DESC" + .to_string(), + vec![Box::new(at.to_string())], + ) + } else { + ( + "SELECT id, component_id, component_type, name, path, app_type, installed_at + FROM installed_components + ORDER BY installed_at DESC" + .to_string(), + vec![], + ) + }; + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?; + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + let installed_iter = stmt + .query_map(¶ms_refs[..], |row| { + let component_type_str: String = row.get(2)?; + let component_type = ComponentType::from_str(&component_type_str) + .ok_or_else(|| rusqlite::Error::InvalidQuery)?; + + let installed_at_str: String = row.get(6)?; + let installed_at = DateTime::parse_from_rfc3339(&installed_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|_| rusqlite::Error::InvalidQuery)?; + + Ok(InstalledComponent { + id: Some(row.get(0)?), + component_id: row.get(1)?, + component_type, + name: row.get(3)?, + path: row.get(4)?, + app_type: row.get(5)?, + installed_at, + }) + }) + .map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?; + + let mut installed = Vec::new(); + for installed_res in installed_iter { + installed.push( + installed_res + .map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?, + ); + } + + Ok(installed) + } + + /// 获取已安装组件列表(支持 app_type 和 component_type 过滤) + pub fn list_installed_components( + &self, + app_type: Option<&str>, + component_type: Option<&str>, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + // 构建 WHERE 子句 + let mut where_clauses = Vec::new(); + let mut params_vec: Vec> = Vec::new(); + + if let Some(at) = app_type { + where_clauses.push("app_type = ?"); + params_vec.push(Box::new(at.to_string())); + } + + if let Some(ct) = component_type { + where_clauses.push("component_type = ?"); + params_vec.push(Box::new(ct.to_string())); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + let sql = format!( + "SELECT id, component_id, component_type, name, path, app_type, installed_at + FROM installed_components + {where_sql} + ORDER BY installed_at DESC" + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?; + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + let installed_iter = stmt + .query_map(¶ms_refs[..], |row| { + let component_type_str: String = row.get(2)?; + let component_type = ComponentType::from_str(&component_type_str) + .ok_or_else(|| rusqlite::Error::InvalidQuery)?; + + let installed_at_str: String = row.get(6)?; + let installed_at = DateTime::parse_from_rfc3339(&installed_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|_| rusqlite::Error::InvalidQuery)?; + + Ok(InstalledComponent { + id: Some(row.get(0)?), + component_id: row.get(1)?, + component_type, + name: row.get(3)?, + path: row.get(4)?, + app_type: row.get(5)?, + installed_at, + }) + }) + .map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))?; + + let mut installed = Vec::new(); + for installed_res in installed_iter { + installed.push( + installed_res + .map_err(|e| AppError::Database(format!("解析已安装组件失败: {e}")))?, + ); + } + + Ok(installed) + } + + /// 检查组件是否已安装 + pub fn is_installed( + &self, + component_type: &str, + path: &str, + app_type: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM installed_components + WHERE component_type = ?1 AND path = ?2 AND app_type = ?3", + params![component_type, path, app_type], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(format!("检查组件安装状态失败: {e}")))?; + + Ok(count > 0) + } + + /// 获取指定应用已安装的组件 ID 列表 + pub fn get_installed_component_ids(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT component_id FROM installed_components WHERE app_type = ?1 AND component_id IS NOT NULL", + ) + .map_err(|e| AppError::Database(format!("准备查询已安装组件失败: {e}")))?; + + let ids: Vec = stmt + .query_map(params![app_type], |row| row.get(0)) + .map_err(|e| AppError::Database(format!("查询已安装组件失败: {e}")))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(ids) + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 73a4877a4..bf59dcd61 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 2; +pub(crate) const SCHEMA_VERSION: i32 = 3; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 134397a40..37d0130c7 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -336,6 +336,90 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 15. Template Repos 表 (模板仓库) + conn.execute( + "CREATE TABLE IF NOT EXISTS template_repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner, name) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 插入默认模板仓库 + conn.execute( + "INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled) + VALUES ('yovinchen', 'claude-code-templates', 'main', 1)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 16. Template Components 表 (模板组件) + conn.execute( + "CREATE TABLE IF NOT EXISTS template_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + component_type TEXT NOT NULL, + category TEXT, + name TEXT NOT NULL, + path TEXT NOT NULL, + description TEXT, + content_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE, + UNIQUE(repo_id, component_type, path) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 为 template_components 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_template_components_type + ON template_components(component_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_template_components_category + ON template_components(category)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 17. Installed Components 表 (已安装组件) + conn.execute( + "CREATE TABLE IF NOT EXISTS installed_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + component_id INTEGER, + component_type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + app_type TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL, + UNIQUE(component_type, path, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 为 installed_components 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_installed_components_app + ON installed_components(app_type)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } @@ -375,6 +459,13 @@ impl Database { Self::migrate_v1_to_v2(conn)?; Self::set_user_version(conn, 2)?; } + 2 => { + log::info!( + "迁移数据库从 v2 到 v3(添加 Claude Code Templates 功能相关表)" + ); + Self::migrate_v2_to_v3(conn)?; + Self::set_user_version(conn, 3)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -617,6 +708,96 @@ impl Database { Ok(()) } + /// v2 -> v3 迁移:添加 Claude Code Templates 功能相关表 + fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> { + // 1. template_repos 表 - 存储模板仓库信息 + conn.execute( + "CREATE TABLE IF NOT EXISTS template_repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner, name) + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建 template_repos 表失败: {e}")))?; + + // 2. template_components 表 - 存储从仓库中发现的模板组件 + conn.execute( + "CREATE TABLE IF NOT EXISTS template_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + component_type TEXT NOT NULL, + category TEXT, + name TEXT NOT NULL, + path TEXT NOT NULL, + description TEXT, + content_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (repo_id) REFERENCES template_repos(id) ON DELETE CASCADE, + UNIQUE(repo_id, component_type, path) + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建 template_components 表失败: {e}")))?; + + // 为 template_components 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_template_components_type + ON template_components(component_type)", + [], + ) + .map_err(|e| AppError::Database(format!("创建 template_components 类型索引失败: {e}")))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_template_components_category + ON template_components(category)", + [], + ) + .map_err(|e| AppError::Database(format!("创建 template_components 分类索引失败: {e}")))?; + + // 3. installed_components 表 - 存储已安装的组件 + conn.execute( + "CREATE TABLE IF NOT EXISTS installed_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + component_id INTEGER, + component_type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + app_type TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (component_id) REFERENCES template_components(id) ON DELETE SET NULL, + UNIQUE(component_type, path, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建 installed_components 表失败: {e}")))?; + + // 为 installed_components 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_installed_components_app + ON installed_components(app_type)", + [], + ) + .map_err(|e| AppError::Database(format!("创建 installed_components 应用索引失败: {e}")))?; + + // 4. 插入默认模板仓库 + conn.execute( + "INSERT OR IGNORE INTO template_repos (owner, name, branch, enabled) + VALUES ('yovinchen', 'claude-code-templates', 'main', 1)", + [], + ) + .map_err(|e| AppError::Database(format!("插入默认模板仓库失败: {e}")))?; + + log::info!("已创建 Claude Code Templates 相关表并插入默认仓库"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 44bf304ac..962ffc544 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -285,8 +285,7 @@ fn model_pricing_is_seeded_on_init() { assert!( count > 0, - "模型定价数据应该在初始化时自动填充,实际数量: {}", - count + "模型定价数据应该在初始化时自动填充,实际数量: {count}" ); // 验证包含 Claude 模型 @@ -299,8 +298,7 @@ fn model_pricing_is_seeded_on_init() { .expect("check claude"); assert!( claude_count > 0, - "应该包含 Claude 模型定价,实际数量: {}", - claude_count + "应该包含 Claude 模型定价,实际数量: {claude_count}" ); // 验证包含 GPT 模型 @@ -313,8 +311,7 @@ fn model_pricing_is_seeded_on_init() { .expect("check gpt"); assert!( gpt_count > 0, - "应该包含 GPT 模型定价,实际数量: {}", - gpt_count + "应该包含 GPT 模型定价,实际数量: {gpt_count}" ); // 验证包含 Gemini 模型 @@ -327,7 +324,90 @@ fn model_pricing_is_seeded_on_init() { .expect("check gemini"); assert!( gemini_count > 0, - "应该包含 Gemini 模型定价,实际数量: {}", - gemini_count + "应该包含 Gemini 模型定价,实际数量: {gemini_count}" ); } + +#[test] +fn test_v2_to_v3_migration_creates_template_tables() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 创建 v2 schema(即当前完整的 schema) + Database::create_tables_on_conn(&conn).expect("create tables"); + Database::set_user_version(&conn, 2).expect("set v2 version"); + + // 应用迁移到 v3 + Database::apply_schema_migrations_on_conn(&conn).expect("migrate to v3"); + + // 验证版本号已更新 + assert_eq!( + Database::get_user_version(&conn).expect("read version"), + 3, + "版本应该更新为 3" + ); + + // 验证 template_repos 表存在且包含默认仓库 + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM template_repos", [], |row| row.get(0)) + .expect("count template_repos"); + assert_eq!(count, 1, "应该有 1 个默认模板仓库"); + + let (owner, name, branch): (String, String, String) = conn + .query_row( + "SELECT owner, name, branch FROM template_repos WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read default repo"); + assert_eq!(owner, "yovinchen", "默认仓库 owner 应该是 yovinchen"); + assert_eq!( + name, "claude-code-templates", + "默认仓库 name 应该是 claude-code-templates" + ); + assert_eq!(branch, "main", "默认仓库 branch 应该是 main"); + + // 验证 template_components 表存在 + assert!( + Database::table_exists(&conn, "template_components").expect("check table"), + "template_components 表应该存在" + ); + + // 验证 installed_components 表存在 + assert!( + Database::table_exists(&conn, "installed_components").expect("check table"), + "installed_components 表应该存在" + ); + + // 验证索引存在 + let index_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND ( + name = 'idx_template_components_type' OR + name = 'idx_template_components_category' OR + name = 'idx_installed_components_app' + )", + [], + |row| row.get(0), + ) + .expect("count indexes"); + assert_eq!(index_count, 3, "应该创建 3 个索引"); + + // 验证外键约束 + let fk_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_list('template_components')", + [], + |row| row.get(0), + ) + .expect("count fk"); + assert_eq!(fk_count, 1, "template_components 应该有 1 个外键约束"); + + let fk_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_list('installed_components')", + [], + |row| row.get(0), + ) + .expect("count fk"); + assert_eq!(fk_count, 1, "installed_components 应该有 1 个外键约束"); +} diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 0f7fb5e45..1834e51d5 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -365,8 +365,7 @@ fn test_parse_prompt_deeplink() { let content = "Hello World"; let content_b64 = BASE64_STANDARD.encode(content); let url = format!( - "ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true", - content_b64 + "ccswitch://v1/import?resource=prompt&app=claude&name=test&content={content_b64}&description=desc&enabled=true" ); let request = parse_deeplink_url(&url).unwrap(); @@ -375,7 +374,7 @@ fn test_parse_prompt_deeplink() { assert_eq!(request.name.unwrap(), "test"); assert_eq!(request.content.unwrap(), content_b64); assert_eq!(request.description.unwrap(), "desc"); - assert_eq!(request.enabled.unwrap(), true); + assert!(request.enabled.unwrap()); } #[test] @@ -383,21 +382,20 @@ fn test_parse_mcp_deeplink() { let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#; let config_b64 = BASE64_STANDARD.encode(config); let url = format!( - "ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true", - config_b64 + "ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true" ); let request = parse_deeplink_url(&url).unwrap(); assert_eq!(request.resource, "mcp"); assert_eq!(request.apps.unwrap(), "claude,codex"); assert_eq!(request.config.unwrap(), config_b64); - assert_eq!(request.enabled.unwrap(), true); + assert!(request.enabled.unwrap()); } #[test] fn test_parse_skill_deeplink() { let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev"; - let request = parse_deeplink_url(&url).unwrap(); + let request = parse_deeplink_url(url).unwrap(); assert_eq!(request.resource, "skill"); assert_eq!(request.repo.unwrap(), "owner/repo"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a4f3a758..e7db3299a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -730,6 +730,21 @@ pub fn run() { commands::get_stream_check_config, commands::save_stream_check_config, commands::get_tool_versions, + // Template management + commands::refresh_template_index, + commands::list_template_components, + commands::get_template_component, + commands::install_template_component, + commands::uninstall_template_component, + commands::batch_install_template_components, + commands::list_template_repos, + commands::add_template_repo, + commands::remove_template_repo, + commands::toggle_template_repo, + commands::list_template_categories, + commands::list_installed_components, + commands::preview_component_content, + commands::list_marketplace_bundles, ]); let app = builder diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c314c70ef..155c91645 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,6 +8,7 @@ pub mod proxy; pub mod skill; pub mod speedtest; pub mod stream_check; +pub mod template; pub mod usage_stats; pub use config::ConfigService; @@ -18,6 +19,12 @@ pub use proxy::ProxyService; pub use skill::{Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; #[allow(unused_imports)] +pub use template::{ + BatchInstallResult, ComponentDetail, ComponentMetadata, ComponentType, InstalledComponent, + MarketplaceBundle, MarketplaceBundleItem, PaginatedResult, TemplateComponent, TemplateRepo, + TemplateService, +}; +#[allow(unused_imports)] pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, RequestLogDetail, UsageSummary, diff --git a/src-tauri/src/services/template/adapters/claude.rs b/src-tauri/src/services/template/adapters/claude.rs new file mode 100644 index 000000000..50530f065 --- /dev/null +++ b/src-tauri/src/services/template/adapters/claude.rs @@ -0,0 +1,267 @@ +//! Claude 应用适配器 +//! +//! 完整支持所有组件类型: +//! - Agent → `~/.claude/agents/{name}.md` +//! - Command → `~/.claude/commands/{name}.md` +//! - MCP → 合并到 `~/.claude.json` 的 mcpServers 字段 +//! - Setting → 合并到 `~/.claude/settings.json` 的 permissions 字段 +//! - Hook → 合并到 `~/.claude/settings.json` 的 hooks 字段 + +use anyhow::{Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::AppAdapter; +use crate::config::{atomic_write, get_claude_config_dir, get_claude_mcp_path}; + +/// Claude 应用适配器 +pub struct ClaudeAdapter { + config_dir: PathBuf, +} + +impl ClaudeAdapter { + /// 创建新的 Claude 适配器实例 + pub fn new() -> Self { + Self { + config_dir: get_claude_config_dir(), + } + } + + /// 读取 JSON 配置文件 + fn read_json_file(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(serde_json::json!({})); + } + let content = fs::read_to_string(path) + .with_context(|| format!("读取配置文件失败: {}", path.display()))?; + let value: Value = serde_json::from_str(&content) + .with_context(|| format!("解析 JSON 失败: {}", path.display()))?; + Ok(value) + } + + /// 写入 JSON 配置文件(原子写入) + fn write_json_file(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } + + let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?; + + atomic_write(path, json.as_bytes()) + .with_context(|| format!("写入配置文件失败: {}", path.display()))?; + + Ok(()) + } + + /// 合并两个 JSON 对象(深度合并) + fn merge_json(base: &mut Value, overlay: &Value) { + if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) { + for (key, value) in overlay_obj { + if let Some(base_value) = base_obj.get_mut(key) { + // 如果两边都是对象,递归合并 + if base_value.is_object() && value.is_object() { + Self::merge_json(base_value, value); + } else { + // 否则直接覆盖 + *base_value = value.clone(); + } + } else { + // 键不存在,直接插入 + base_obj.insert(key.clone(), value.clone()); + } + } + } + } + + /// 安装 Markdown 文件(通用) + fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result { + let dir = self.config_dir.join(subdir); + fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?; + + let filename = if name.ends_with(".md") { + name.to_string() + } else { + format!("{name}.md") + }; + + let file_path = dir.join(&filename); + + atomic_write(&file_path, content.as_bytes()) + .with_context(|| format!("写入文件失败: {}", file_path.display()))?; + + log::info!("已安装 Claude {}: {}", subdir, file_path.display()); + Ok(file_path) + } + + /// 获取 Claude settings.json 路径 + fn get_settings_path(&self) -> PathBuf { + crate::config::get_claude_settings_path() + } +} + +impl AppAdapter for ClaudeAdapter { + fn install_agent(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "agents", name) + } + + fn install_command(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "commands", name) + } + + fn install_mcp(&self, mcp_config: &Value) -> Result<()> { + let mcp_path = get_claude_mcp_path(); + + // 读取现有 MCP 配置 + let mut current = Self::read_json_file(&mcp_path)?; + + // 确保 mcpServers 字段存在 + if !current.is_object() { + current = serde_json::json!({}); + } + if current.get("mcpServers").is_none() { + current["mcpServers"] = serde_json::json!({}); + } + + // 合并新的 MCP 服务器配置 + if let Some(mcp_servers) = current.get_mut("mcpServers") { + Self::merge_json(mcp_servers, mcp_config); + } + + // 写回配置文件 + Self::write_json_file(&mcp_path, ¤t)?; + + log::info!("已安装 Claude MCP 配置到: {}", mcp_path.display()); + Ok(()) + } + + fn install_setting(&self, setting_config: &Value) -> Result<()> { + let settings_path = self.get_settings_path(); + + // 读取现有配置 + let mut current = Self::read_json_file(&settings_path)?; + + // 确保 permissions 字段存在 + if !current.is_object() { + current = serde_json::json!({}); + } + if current.get("permissions").is_none() { + current["permissions"] = serde_json::json!({}); + } + + // 合并新的 permissions 配置 + if let Some(permissions) = current.get_mut("permissions") { + Self::merge_json(permissions, setting_config); + } + + // 写回配置文件 + Self::write_json_file(&settings_path, ¤t)?; + + log::info!("已安装 Claude Setting 配置到: {}", settings_path.display()); + Ok(()) + } + + fn install_hook(&self, hook_config: &Value) -> Result<()> { + let settings_path = self.get_settings_path(); + + // 读取现有配置 + let mut current = Self::read_json_file(&settings_path)?; + + // 确保 hooks 字段存在 + if !current.is_object() { + current = serde_json::json!({}); + } + if current.get("hooks").is_none() { + current["hooks"] = serde_json::json!({}); + } + + // 合并新的 hooks 配置 + if let Some(hooks) = current.get_mut("hooks") { + Self::merge_json(hooks, hook_config); + } + + // 写回配置文件 + Self::write_json_file(&settings_path, ¤t)?; + + log::info!("已安装 Claude Hook 配置到: {}", settings_path.display()); + Ok(()) + } + + fn uninstall(&self, component_type: &str, name: &str) -> Result<()> { + match component_type.to_lowercase().as_str() { + "agent" => { + let path = self.config_dir.join("agents").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?; + log::info!("已卸载 Claude Agent: {}", path.display()); + } + } + "command" => { + let path = self.config_dir.join("commands").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Command 文件失败: {}", path.display()))?; + log::info!("已卸载 Claude Command: {}", path.display()); + } + } + "mcp" => { + let mcp_path = get_claude_mcp_path(); + let mut current = Self::read_json_file(&mcp_path)?; + + if let Some(mcp_servers) = current + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + { + mcp_servers.remove(name); + Self::write_json_file(&mcp_path, ¤t)?; + log::info!("已卸载 Claude MCP 服务器: {name}"); + } + } + "setting" => { + let settings_path = self.get_settings_path(); + let mut current = Self::read_json_file(&settings_path)?; + + if let Some(permissions) = current + .get_mut("permissions") + .and_then(|v| v.as_object_mut()) + { + permissions.remove(name); + Self::write_json_file(&settings_path, ¤t)?; + log::info!("已卸载 Claude Setting: {name}"); + } + } + "hook" => { + let settings_path = self.get_settings_path(); + let mut current = Self::read_json_file(&settings_path)?; + + if let Some(hooks) = current.get_mut("hooks").and_then(|v| v.as_object_mut()) { + hooks.remove(name); + Self::write_json_file(&settings_path, ¤t)?; + log::info!("已卸载 Claude Hook: {name}"); + } + } + _ => anyhow::bail!("不支持的组件类型: {component_type}"), + } + + Ok(()) + } + + fn config_dir(&self) -> PathBuf { + self.config_dir.clone() + } + + fn supports_component_type(&self, component_type: &str) -> bool { + matches!( + component_type.to_lowercase().as_str(), + "agent" | "command" | "mcp" | "setting" | "hook" + ) + } +} + +impl Default for ClaudeAdapter { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/services/template/adapters/codex.rs b/src-tauri/src/services/template/adapters/codex.rs new file mode 100644 index 000000000..9a6fb2284 --- /dev/null +++ b/src-tauri/src/services/template/adapters/codex.rs @@ -0,0 +1,299 @@ +//! Codex 应用适配器 +//! +//! 部分支持: +//! - Agent → `~/.codex/agents/{name}.md` +//! - Command → `~/.codex/commands/{name}.md` +//! - MCP → 合并到 `~/.codex/config.toml` 的 [mcp_servers] 表 +//! - Setting/Hook → 不支持(Codex 不支持这些功能) + +use anyhow::{bail, Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::AppAdapter; +use crate::codex_config::get_codex_config_dir; +use crate::config::{atomic_write, write_text_file}; + +/// Codex 应用适配器 +pub struct CodexAdapter { + config_dir: PathBuf, +} + +impl CodexAdapter { + /// 创建新的 Codex 适配器实例 + pub fn new() -> Self { + Self { + config_dir: get_codex_config_dir(), + } + } + + /// 安装 Markdown 文件(通用) + fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result { + let dir = self.config_dir.join(subdir); + fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?; + + let filename = if name.ends_with(".md") { + name.to_string() + } else { + format!("{name}.md") + }; + + let file_path = dir.join(&filename); + + atomic_write(&file_path, content.as_bytes()) + .with_context(|| format!("写入文件失败: {}", file_path.display()))?; + + log::info!("已安装 Codex {}: {}", subdir, file_path.display()); + Ok(file_path) + } + + /// 获取 Codex config.toml 路径 + fn get_config_toml_path(&self) -> PathBuf { + crate::codex_config::get_codex_config_path() + } + + /// 读取 TOML 配置文件 + fn read_toml_file(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(toml::Table::new()); + } + let content = fs::read_to_string(path) + .with_context(|| format!("读取配置文件失败: {}", path.display()))?; + let table: toml::Table = toml::from_str(&content) + .with_context(|| format!("解析 TOML 失败: {}", path.display()))?; + Ok(table) + } + + /// 写入 TOML 配置文件(原子写入) + fn write_toml_file(path: &Path, table: &toml::Table) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } + + let toml_string = toml::to_string_pretty(table).context("序列化 TOML 失败")?; + + write_text_file(path, &toml_string) + .with_context(|| format!("写入配置文件失败: {}", path.display()))?; + + Ok(()) + } + + /// 将 JSON MCP 配置转换为 TOML 格式 + fn json_mcp_to_toml(json_config: &Value) -> Result { + let mut mcp_servers = toml::Table::new(); + + if let Some(obj) = json_config.as_object() { + for (server_id, server_spec) in obj { + let mut server_table = toml::Table::new(); + + if let Some(spec_obj) = server_spec.as_object() { + // type 字段(默认 stdio) + let server_type = spec_obj + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("stdio"); + server_table.insert( + "type".to_string(), + toml::Value::String(server_type.to_string()), + ); + + match server_type { + "stdio" => { + // command 字段(必需) + if let Some(cmd) = spec_obj.get("command").and_then(|v| v.as_str()) { + server_table.insert( + "command".to_string(), + toml::Value::String(cmd.to_string()), + ); + } + + // args 字段(可选) + if let Some(args) = spec_obj.get("args").and_then(|v| v.as_array()) { + let toml_args: Vec = args + .iter() + .filter_map(|v| v.as_str()) + .map(|s| toml::Value::String(s.to_string())) + .collect(); + if !toml_args.is_empty() { + server_table + .insert("args".to_string(), toml::Value::Array(toml_args)); + } + } + + // env 字段(可选) + if let Some(env) = spec_obj.get("env").and_then(|v| v.as_object()) { + let mut env_table = toml::Table::new(); + for (key, value) in env { + if let Some(val_str) = value.as_str() { + env_table.insert( + key.clone(), + toml::Value::String(val_str.to_string()), + ); + } + } + if !env_table.is_empty() { + server_table + .insert("env".to_string(), toml::Value::Table(env_table)); + } + } + + // cwd 字段(可选) + if let Some(cwd) = spec_obj.get("cwd").and_then(|v| v.as_str()) { + server_table.insert( + "cwd".to_string(), + toml::Value::String(cwd.to_string()), + ); + } + } + "http" | "sse" => { + // url 字段(必需) + if let Some(url) = spec_obj.get("url").and_then(|v| v.as_str()) { + server_table.insert( + "url".to_string(), + toml::Value::String(url.to_string()), + ); + } + + // http_headers 字段(可选) + if let Some(headers) = + spec_obj.get("http_headers").and_then(|v| v.as_object()) + { + let mut headers_table = toml::Table::new(); + for (key, value) in headers { + if let Some(val_str) = value.as_str() { + headers_table.insert( + key.clone(), + toml::Value::String(val_str.to_string()), + ); + } + } + if !headers_table.is_empty() { + server_table.insert( + "http_headers".to_string(), + toml::Value::Table(headers_table), + ); + } + } + } + _ => {} + } + } + + mcp_servers.insert(server_id.clone(), toml::Value::Table(server_table)); + } + } + + Ok(mcp_servers) + } +} + +impl AppAdapter for CodexAdapter { + fn install_agent(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "agents", name) + } + + fn install_command(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "commands", name) + } + + fn install_mcp(&self, mcp_config: &Value) -> Result<()> { + let config_path = self.get_config_toml_path(); + + // 读取现有 TOML 配置 + let mut current = Self::read_toml_file(&config_path)?; + + // 确保 mcp_servers 表存在 + if !current.contains_key("mcp_servers") { + current.insert( + "mcp_servers".to_string(), + toml::Value::Table(toml::Table::new()), + ); + } + + // 转换 JSON MCP 配置到 TOML + let new_mcp_servers = Self::json_mcp_to_toml(mcp_config)?; + + // 合并 MCP 服务器配置 + if let Some(mcp_servers) = current + .get_mut("mcp_servers") + .and_then(|v| v.as_table_mut()) + { + for (server_id, server_config) in new_mcp_servers { + mcp_servers.insert(server_id, server_config); + } + } + + // 写回配置文件 + Self::write_toml_file(&config_path, ¤t)?; + + log::info!("已安装 Codex MCP 配置到: {}", config_path.display()); + Ok(()) + } + + fn install_setting(&self, _setting_config: &Value) -> Result<()> { + bail!("Codex 不支持 Setting 配置") + } + + fn install_hook(&self, _hook_config: &Value) -> Result<()> { + bail!("Codex 不支持 Hook 配置") + } + + fn uninstall(&self, component_type: &str, name: &str) -> Result<()> { + match component_type.to_lowercase().as_str() { + "agent" => { + let path = self.config_dir.join("agents").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?; + log::info!("已卸载 Codex Agent: {}", path.display()); + } + } + "command" => { + let path = self.config_dir.join("commands").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Command 文件失败: {}", path.display()))?; + log::info!("已卸载 Codex Command: {}", path.display()); + } + } + "mcp" => { + let config_path = self.get_config_toml_path(); + let mut current = Self::read_toml_file(&config_path)?; + + if let Some(mcp_servers) = current + .get_mut("mcp_servers") + .and_then(|v| v.as_table_mut()) + { + mcp_servers.remove(name); + Self::write_toml_file(&config_path, ¤t)?; + log::info!("已卸载 Codex MCP 服务器: {name}"); + } + } + "setting" | "hook" => { + bail!("Codex 不支持 {component_type} 组件类型") + } + _ => bail!("不支持的组件类型: {component_type}"), + } + + Ok(()) + } + + fn config_dir(&self) -> PathBuf { + self.config_dir.clone() + } + + fn supports_component_type(&self, component_type: &str) -> bool { + matches!( + component_type.to_lowercase().as_str(), + "agent" | "command" | "mcp" + ) + } +} + +impl Default for CodexAdapter { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/services/template/adapters/gemini.rs b/src-tauri/src/services/template/adapters/gemini.rs new file mode 100644 index 000000000..8e0539eff --- /dev/null +++ b/src-tauri/src/services/template/adapters/gemini.rs @@ -0,0 +1,239 @@ +//! Gemini 应用适配器 +//! +//! 部分支持: +//! - Agent → `~/.gemini/agents/{name}.md` +//! - Command → `~/.gemini/commands/{name}.md` +//! - MCP → 合并到 `~/.gemini/settings.json` 的 mcpServers 字段 +//! - Setting/Hook → 不支持(Gemini 不支持这些功能) + +use anyhow::{bail, Context, Result}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::AppAdapter; +use crate::config::atomic_write; +use crate::gemini_config::{get_gemini_dir, get_gemini_settings_path}; + +/// Gemini 应用适配器 +pub struct GeminiAdapter { + config_dir: PathBuf, +} + +impl GeminiAdapter { + /// 创建新的 Gemini 适配器实例 + pub fn new() -> Self { + Self { + config_dir: get_gemini_dir(), + } + } + + /// 读取 JSON 配置文件 + fn read_json_file(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(serde_json::json!({})); + } + let content = fs::read_to_string(path) + .with_context(|| format!("读取配置文件失败: {}", path.display()))?; + let value: Value = serde_json::from_str(&content) + .with_context(|| format!("解析 JSON 失败: {}", path.display()))?; + Ok(value) + } + + /// 写入 JSON 配置文件(原子写入) + fn write_json_file(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } + + let json = serde_json::to_string_pretty(value).context("序列化 JSON 失败")?; + + atomic_write(path, json.as_bytes()) + .with_context(|| format!("写入配置文件失败: {}", path.display()))?; + + Ok(()) + } + + /// 合并两个 JSON 对象(深度合并) + fn merge_json(base: &mut Value, overlay: &Value) { + if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) { + for (key, value) in overlay_obj { + if let Some(base_value) = base_obj.get_mut(key) { + // 如果两边都是对象,递归合并 + if base_value.is_object() && value.is_object() { + Self::merge_json(base_value, value); + } else { + // 否则直接覆盖 + *base_value = value.clone(); + } + } else { + // 键不存在,直接插入 + base_obj.insert(key.clone(), value.clone()); + } + } + } + } + + /// 安装 Markdown 文件(通用) + fn install_markdown_file(&self, content: &str, subdir: &str, name: &str) -> Result { + let dir = self.config_dir.join(subdir); + fs::create_dir_all(&dir).with_context(|| format!("创建目录失败: {}", dir.display()))?; + + let filename = if name.ends_with(".md") { + name.to_string() + } else { + format!("{name}.md") + }; + + let file_path = dir.join(&filename); + + atomic_write(&file_path, content.as_bytes()) + .with_context(|| format!("写入文件失败: {}", file_path.display()))?; + + log::info!("已安装 Gemini {}: {}", subdir, file_path.display()); + Ok(file_path) + } + + /// 获取 Gemini settings.json 路径 + fn get_settings_path(&self) -> PathBuf { + get_gemini_settings_path() + } + + /// 转换 MCP 配置为 Gemini 格式 + /// + /// Gemini 使用特殊格式: + /// - HTTP 类型:使用 `httpUrl` 而不是 `url` + `type: "http"` + /// - SSE/stdio 类型:保持标准格式 + fn transform_mcp_to_gemini(mcp_config: &Value) -> Result { + let mut transformed = mcp_config.clone(); + + if let Some(obj) = transformed.as_object_mut() { + for (_server_id, server_spec) in obj.iter_mut() { + if let Some(spec_obj) = server_spec.as_object_mut() { + // 检查是否为 HTTP 类型 + let is_http = spec_obj + .get("type") + .and_then(|v| v.as_str()) + .map(|t| t == "http") + .unwrap_or(false); + + if is_http { + // 将 url 字段转换为 httpUrl + if let Some(url) = spec_obj.remove("url") { + spec_obj.insert("httpUrl".to_string(), url); + } + // 移除 type 字段(Gemini 不需要显式指定 type) + spec_obj.remove("type"); + } + } + } + } + + Ok(transformed) + } +} + +impl AppAdapter for GeminiAdapter { + fn install_agent(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "agents", name) + } + + fn install_command(&self, content: &str, name: &str) -> Result { + self.install_markdown_file(content, "commands", name) + } + + fn install_mcp(&self, mcp_config: &Value) -> Result<()> { + let settings_path = self.get_settings_path(); + + // 读取现有配置 + let mut current = Self::read_json_file(&settings_path)?; + + // 确保 mcpServers 字段存在 + if !current.is_object() { + current = serde_json::json!({}); + } + if current.get("mcpServers").is_none() { + current["mcpServers"] = serde_json::json!({}); + } + + // 转换 MCP 配置为 Gemini 格式 + let transformed = Self::transform_mcp_to_gemini(mcp_config)?; + + // 合并新的 MCP 服务器配置 + if let Some(mcp_servers) = current.get_mut("mcpServers") { + Self::merge_json(mcp_servers, &transformed); + } + + // 写回配置文件 + Self::write_json_file(&settings_path, ¤t)?; + + log::info!("已安装 Gemini MCP 配置到: {}", settings_path.display()); + Ok(()) + } + + fn install_setting(&self, _setting_config: &Value) -> Result<()> { + bail!("Gemini 不支持 Setting 配置") + } + + fn install_hook(&self, _hook_config: &Value) -> Result<()> { + bail!("Gemini 不支持 Hook 配置") + } + + fn uninstall(&self, component_type: &str, name: &str) -> Result<()> { + match component_type.to_lowercase().as_str() { + "agent" => { + let path = self.config_dir.join("agents").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Agent 文件失败: {}", path.display()))?; + log::info!("已卸载 Gemini Agent: {}", path.display()); + } + } + "command" => { + let path = self.config_dir.join("commands").join(format!("{name}.md")); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("删除 Command 文件失败: {}", path.display()))?; + log::info!("已卸载 Gemini Command: {}", path.display()); + } + } + "mcp" => { + let settings_path = self.get_settings_path(); + let mut current = Self::read_json_file(&settings_path)?; + + if let Some(mcp_servers) = current + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + { + mcp_servers.remove(name); + Self::write_json_file(&settings_path, ¤t)?; + log::info!("已卸载 Gemini MCP 服务器: {name}"); + } + } + "setting" | "hook" => { + bail!("Gemini 不支持 {component_type} 组件类型") + } + _ => bail!("不支持的组件类型: {component_type}"), + } + + Ok(()) + } + + fn config_dir(&self) -> PathBuf { + self.config_dir.clone() + } + + fn supports_component_type(&self, component_type: &str) -> bool { + matches!( + component_type.to_lowercase().as_str(), + "agent" | "command" | "mcp" + ) + } +} + +impl Default for GeminiAdapter { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/services/template/adapters/mod.rs b/src-tauri/src/services/template/adapters/mod.rs new file mode 100644 index 000000000..336a9fdc9 --- /dev/null +++ b/src-tauri/src/services/template/adapters/mod.rs @@ -0,0 +1,107 @@ +//! 应用适配器模块 +//! +//! 负责将 Template 组件安装到不同应用的配置目录中。 +//! 每个应用有独立的适配器实现,处理各自的配置格式和目录结构。 + +mod claude; +mod codex; +mod gemini; + +use anyhow::Result; +use std::path::PathBuf; + +pub use claude::ClaudeAdapter; +pub use codex::CodexAdapter; +pub use gemini::GeminiAdapter; + +use crate::app_config::AppType; + +/// 应用适配器 trait +/// +/// 定义了将 Template 组件安装到应用配置目录的统一接口。 +/// 每个应用实现自己的适配器来处理特定的配置格式和目录结构。 +#[allow(dead_code)] +pub trait AppAdapter: Send + Sync { + /// 安装 Agent 到应用配置目录 + /// + /// # 参数 + /// - `content`: Agent 内容(Markdown 格式) + /// - `name`: Agent 名称(用作文件名) + /// + /// # 返回 + /// 安装后的文件路径 + fn install_agent(&self, content: &str, name: &str) -> Result; + + /// 安装 Command 到应用配置目录 + /// + /// # 参数 + /// - `content`: Command 内容(Markdown 格式) + /// - `name`: Command 名称(用作文件名) + /// + /// # 返回 + /// 安装后的文件路径 + fn install_command(&self, content: &str, name: &str) -> Result; + + /// 安装 MCP 服务器配置 + /// + /// # 参数 + /// - `mcp_config`: MCP 服务器配置(JSON 对象) + /// + /// # 说明 + /// 配置会合并到应用的 MCP 配置文件中,保留现有配置。 + fn install_mcp(&self, mcp_config: &serde_json::Value) -> Result<()>; + + /// 安装 Setting (permissions) + /// + /// # 参数 + /// - `setting_config`: Setting 配置(JSON 对象) + /// + /// # 说明 + /// 仅 Claude 支持此功能,会合并到 settings.json 的 permissions 字段。 + fn install_setting(&self, setting_config: &serde_json::Value) -> Result<()>; + + /// 安装 Hook + /// + /// # 参数 + /// - `hook_config`: Hook 配置(JSON 对象) + /// + /// # 说明 + /// 仅 Claude 支持此功能,会合并到 settings.json 的 hooks 字段。 + fn install_hook(&self, hook_config: &serde_json::Value) -> Result<()>; + + /// 卸载组件 + /// + /// # 参数 + /// - `component_type`: 组件类型(agent/command/mcp/setting/hook) + /// - `name`: 组件名称或 ID + fn uninstall(&self, component_type: &str, name: &str) -> Result<()>; + + /// 获取配置目录路径 + fn config_dir(&self) -> PathBuf; + + /// 检查组件类型是否支持 + /// + /// # 参数 + /// - `component_type`: 组件类型字符串 + /// + /// # 返回 + /// 如果应用支持该组件类型返回 true,否则返回 false + #[allow(dead_code)] + fn supports_component_type(&self, component_type: &str) -> bool; +} + +/// 创建应用适配器工厂函数 +/// +/// # 参数 +/// - `app_type`: 应用类型 +/// +/// # 返回 +/// 对应应用的适配器实例 +#[allow(dead_code)] +pub fn create_adapter(app_type: &AppType) -> Box { + match app_type { + AppType::Claude => Box::new(ClaudeAdapter::new()), + AppType::Codex => Box::new(CodexAdapter::new()), + AppType::Gemini => Box::new(GeminiAdapter::new()), + } +} diff --git a/src-tauri/src/services/template/index.rs b/src-tauri/src/services/template/index.rs new file mode 100644 index 000000000..5da852f83 --- /dev/null +++ b/src-tauri/src/services/template/index.rs @@ -0,0 +1,746 @@ +use anyhow::{anyhow, Context, Result}; +use rusqlite::{params, Connection}; +use std::fs; +use std::path::{Path, PathBuf}; +use tokio::time::timeout; + +use super::{ComponentMetadata, ComponentType, TemplateComponent, TemplateRepo, TemplateService}; + +impl TemplateService { + /// 刷新所有启用仓库的组件索引 + pub async fn refresh_index(&self, conn: &Connection) -> Result<()> { + // 获取所有启用的仓库 + let repos = self.list_enabled_repos(conn)?; + + if repos.is_empty() { + log::info!("没有启用的模板仓库"); + return Ok(()); + } + + log::info!("开始刷新 {} 个模板仓库", repos.len()); + + // 并行扫描所有仓库 + let scan_tasks = repos.iter().map(|repo| self.scan_repo(repo)); + let results: Vec>> = + futures::future::join_all(scan_tasks).await; + + // 处理扫描结果 + let mut total_components = 0; + for (repo, result) in repos.iter().zip(results.into_iter()) { + match result { + Ok(components) => { + log::info!( + "仓库 {}/{} 扫描到 {} 个组件", + repo.owner, + repo.name, + components.len() + ); + + // 保存到数据库 + if let Err(e) = self.save_components(conn, &components) { + log::error!("保存组件到数据库失败: {e}"); + } else { + total_components += components.len(); + } + } + Err(e) => { + log::warn!("扫描仓库 {}/{} 失败: {}", repo.owner, repo.name, e); + } + } + } + + log::info!("刷新完成,共索引 {total_components} 个组件"); + Ok(()) + } + + /// 扫描单个仓库 + pub async fn scan_repo(&self, repo: &TemplateRepo) -> Result> { + log::info!("开始扫描仓库: {}/{}", repo.owner, repo.name); + + // 下载仓库(增加超时控制) + let temp_dir = timeout( + std::time::Duration::from_secs(120), + self.download_repo(repo), + ) + .await + .map_err(|_| anyhow!("下载仓库超时: {}/{}", repo.owner, repo.name))??; + + let mut components = Vec::new(); + + // 扫描不同类型的组件 + self.scan_agents(&temp_dir, repo, &mut components)?; + self.scan_commands(&temp_dir, repo, &mut components)?; + self.scan_mcps(&temp_dir, repo, &mut components)?; + self.scan_settings(&temp_dir, repo, &mut components)?; + self.scan_hooks(&temp_dir, repo, &mut components)?; + self.scan_skills(&temp_dir, repo, &mut components)?; + + // 清理临时目录 + let _ = fs::remove_dir_all(&temp_dir); + + log::info!( + "仓库 {}/{} 扫描完成,找到 {} 个组件", + repo.owner, + repo.name, + components.len() + ); + + Ok(components) + } + + /// 下载仓库 ZIP + async fn download_repo(&self, repo: &TemplateRepo) -> Result { + let temp_dir = tempfile::tempdir().context("创建临时目录失败")?; + let temp_path = temp_dir.path().to_path_buf(); + let _ = temp_dir.keep(); + + // 尝试多个分支 + let branches = if repo.branch.is_empty() { + vec!["main", "master"] + } else { + vec![repo.branch.as_str(), "main", "master"] + }; + + let mut last_error = None; + for branch in branches { + let url = format!( + "https://github.com/{}/{}/archive/refs/heads/{}.zip", + repo.owner, repo.name, branch + ); + + log::debug!("尝试下载: {url}"); + match self.download_and_extract(&url, &temp_path).await { + Ok(_) => { + log::info!("成功下载仓库: {}/{} ({})", repo.owner, repo.name, branch); + return Ok(temp_path); + } + Err(e) => { + log::debug!("下载分支 {branch} 失败: {e}"); + last_error = Some(e); + continue; + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("所有分支下载失败"))) + } + + /// 下载并解压 ZIP + async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { + // 下载 ZIP + let response = self.client().get(url).send().await?; + if !response.status().is_success() { + anyhow::bail!("下载失败: HTTP {}", response.status()); + } + + let bytes = response.bytes().await?; + + // 解压 + let cursor = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor)?; + + // 获取根目录名称 + let root_name = if !archive.is_empty() { + let first_file = archive.by_index(0)?; + let name = first_file.name(); + name.split('/').next().unwrap_or("").to_string() + } else { + return Err(anyhow!("空的压缩包")); + }; + + // 解压所有文件 + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let file_path = file.name(); + + // 跳过根目录,直接提取内容 + let relative_path = + if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) { + stripped + } else { + continue; + }; + + if relative_path.is_empty() { + continue; + } + + let outpath = dest.join(relative_path); + + if file.is_dir() { + fs::create_dir_all(&outpath)?; + } else { + if let Some(parent) = outpath.parent() { + fs::create_dir_all(parent)?; + } + let mut outfile = fs::File::create(&outpath)?; + std::io::copy(&mut file, &mut outfile)?; + } + } + + Ok(()) + } + + /// 扫描 Agents + fn scan_agents( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + // 尝试多个可能的路径 + let paths = [ + base_dir.join("cli-tool").join("components").join("agents"), + base_dir.join("src").join("agents"), + base_dir.join("components").join("agents"), + ]; + for agents_dir in paths { + if agents_dir.exists() { + self.scan_markdown_components( + &agents_dir, + base_dir, + ComponentType::Agent, + repo, + components, + )?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 Commands + fn scan_commands( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let paths = [ + base_dir + .join("cli-tool") + .join("components") + .join("commands"), + base_dir.join("src").join("commands"), + base_dir.join("components").join("commands"), + ]; + for commands_dir in paths { + if commands_dir.exists() { + self.scan_markdown_components( + &commands_dir, + base_dir, + ComponentType::Command, + repo, + components, + )?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 MCPs + fn scan_mcps( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let paths = [ + base_dir.join("cli-tool").join("components").join("mcps"), + base_dir.join("src").join("mcp"), + base_dir.join("components").join("mcps"), + ]; + for mcps_dir in paths { + if mcps_dir.exists() { + self.scan_json_components( + &mcps_dir, + base_dir, + ComponentType::Mcp, + repo, + components, + )?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 Settings + fn scan_settings( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let paths = [ + base_dir + .join("cli-tool") + .join("components") + .join("settings"), + base_dir.join("src").join("settings"), + base_dir.join("components").join("settings"), + ]; + for settings_dir in paths { + if settings_dir.exists() { + self.scan_json_components( + &settings_dir, + base_dir, + ComponentType::Setting, + repo, + components, + )?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 Hooks + fn scan_hooks( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let paths = [ + base_dir.join("cli-tool").join("components").join("hooks"), + base_dir.join("src").join("hooks"), + base_dir.join("components").join("hooks"), + ]; + for hooks_dir in paths { + if hooks_dir.exists() { + self.scan_json_components( + &hooks_dir, + base_dir, + ComponentType::Hook, + repo, + components, + )?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 Skills + fn scan_skills( + &self, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let paths = [ + base_dir.join("cli-tool").join("components").join("skills"), + base_dir.join("src").join("skills"), + base_dir.join("components").join("skills"), + ]; + for skills_dir in paths { + if skills_dir.exists() { + self.scan_skills_recursive(&skills_dir, base_dir, repo, components)?; + return Ok(()); + } + } + Ok(()) + } + + /// 扫描 Markdown 组件(Agent/Command) + fn scan_markdown_components( + &self, + dir: &Path, + base_dir: &Path, + component_type: ComponentType, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") { + if let Ok(component) = + self.parse_markdown_component(&path, base_dir, component_type.clone(), repo) + { + components.push(component); + } + } else if path.is_dir() { + // 递归扫描子目录(用于分类) + self.scan_markdown_components( + &path, + base_dir, + component_type.clone(), + repo, + components, + )?; + } + } + + Ok(()) + } + + /// 扫描 JSON 组件(MCP/Setting/Hook) + fn scan_json_components( + &self, + dir: &Path, + base_dir: &Path, + component_type: ComponentType, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(component) = + self.parse_json_component(&path, base_dir, component_type.clone(), repo) + { + components.push(component); + } + } else if path.is_dir() { + // 递归扫描子目录(用于分类) + self.scan_json_components( + &path, + base_dir, + component_type.clone(), + repo, + components, + )?; + } + } + + Ok(()) + } + + /// 递归扫描技能目录 + fn scan_skills_recursive( + &self, + current_dir: &Path, + base_dir: &Path, + repo: &TemplateRepo, + components: &mut Vec, + ) -> Result<()> { + let skill_md = current_dir.join("SKILL.md"); + + if skill_md.exists() { + // 发现技能 + if let Ok(component) = self.parse_skill_component(&skill_md, base_dir, repo) { + components.push(component); + } + return Ok(()); + } + + // 继续递归扫描子目录 + for entry in fs::read_dir(current_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + self.scan_skills_recursive(&path, base_dir, repo, components)?; + } + } + + Ok(()) + } + + /// 解析 Markdown 组件元数据 + fn parse_markdown_component( + &self, + path: &Path, + base_dir: &Path, + component_type: ComponentType, + repo: &TemplateRepo, + ) -> Result { + let content = fs::read_to_string(path)?; + let meta = self.parse_component_metadata(&content)?; + + let file_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + // 提取分类(从目录结构) + let category = self.extract_category(path, &format!("src/{}", component_type.as_str())); + + // 计算相对于仓库根目录的路径 + let relative_path = path + .strip_prefix(base_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + Ok(TemplateComponent { + id: None, + repo_id: repo.id.unwrap_or(0), + component_type, + category, + name: meta.name.unwrap_or_else(|| file_name.to_string()), + path: relative_path, + description: meta.description, + content_hash: Some(Self::calculate_hash(&content)), + installed: false, + }) + } + + /// 解析 JSON 组件元数据 + fn parse_json_component( + &self, + path: &Path, + base_dir: &Path, + component_type: ComponentType, + repo: &TemplateRepo, + ) -> Result { + let content = fs::read_to_string(path)?; + let json: serde_json::Value = serde_json::from_str(&content)?; + + let file_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let name = json + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(file_name) + .to_string(); + + let description = json + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + let category = self.extract_category(path, &format!("src/{}", component_type.as_str())); + + // 计算相对于仓库根目录的路径 + let relative_path = path + .strip_prefix(base_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + Ok(TemplateComponent { + id: None, + repo_id: repo.id.unwrap_or(0), + component_type, + category, + name, + path: relative_path, + description, + content_hash: Some(Self::calculate_hash(&content)), + installed: false, + }) + } + + /// 解析技能组件 + fn parse_skill_component( + &self, + skill_md: &Path, + base_dir: &Path, + repo: &TemplateRepo, + ) -> Result { + let content = fs::read_to_string(skill_md)?; + let meta = self.parse_component_metadata(&content)?; + + let skill_dir = skill_md.parent().unwrap(); + let directory = skill_dir + .strip_prefix(base_dir) + .unwrap_or(skill_dir) + .to_string_lossy() + .to_string(); + + Ok(TemplateComponent { + id: None, + repo_id: repo.id.unwrap_or(0), + component_type: ComponentType::Skill, + category: None, + name: meta.name.unwrap_or_else(|| directory.clone()), + path: directory, + description: meta.description, + content_hash: Some(Self::calculate_hash(&content)), + installed: false, + }) + } + + /// 解析组件元数据(从 front matter) + pub fn parse_component_metadata(&self, content: &str) -> Result { + // 移除 BOM + let content = content.trim_start_matches('\u{feff}'); + + // 提取 YAML front matter + let parts: Vec<&str> = content.splitn(3, "---").collect(); + if parts.len() < 3 { + return Ok(ComponentMetadata { + name: None, + description: None, + tools: None, + model: None, + }); + } + + let front_matter = parts[1].trim(); + let meta: ComponentMetadata = + serde_yaml::from_str(front_matter).unwrap_or(ComponentMetadata { + name: None, + description: None, + tools: None, + model: None, + }); + + Ok(meta) + } + + /// 提取分类(从路径) + fn extract_category(&self, path: &Path, base: &str) -> Option { + let path_str = path.to_string_lossy(); + if let Some(pos) = path_str.find(base) { + let after_base = &path_str[pos + base.len()..]; + let parts: Vec<&str> = after_base.split('/').filter(|s| !s.is_empty()).collect(); + if parts.len() > 1 { + return Some(parts[0].to_string()); + } + } + None + } + + /// 计算内容哈希 + fn calculate_hash(content: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + /// 保存组件到数据库 + fn save_components(&self, conn: &Connection, components: &[TemplateComponent]) -> Result<()> { + for component in components { + // 检查是否已存在(通过 repo_id + component_type + path) + let existing: Option = conn + .query_row( + "SELECT id FROM template_components + WHERE repo_id = ?1 AND component_type = ?2 AND path = ?3", + params![ + component.repo_id, + component.component_type.as_str(), + &component.path + ], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing { + // 更新现有组件 + conn.execute( + "UPDATE template_components + SET category = ?1, name = ?2, description = ?3, content_hash = ?4, updated_at = CURRENT_TIMESTAMP + WHERE id = ?5", + params![ + &component.category, + &component.name, + &component.description, + &component.content_hash, + id + ], + )?; + } else { + // 插入新组件 + conn.execute( + "INSERT INTO template_components (repo_id, component_type, category, name, path, description, content_hash) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + component.repo_id, + component.component_type.as_str(), + &component.category, + &component.name, + &component.path, + &component.description, + &component.content_hash + ], + )?; + } + } + + Ok(()) + } + + /// 列出组件(支持过滤和分页) + #[allow(dead_code)] + pub fn list_components( + &self, + conn: &Connection, + component_type: Option, + category: Option, + search: Option, + page: u32, + page_size: u32, + ) -> Result> { + let mut where_clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ct) = &component_type { + where_clauses.push("component_type = ?"); + params.push(Box::new(ct.as_str().to_string())); + } + + if let Some(cat) = &category { + where_clauses.push("category = ?"); + params.push(Box::new(cat.clone())); + } + + if let Some(s) = &search { + where_clauses.push("(name LIKE ? OR description LIKE ?)"); + let search_pattern = format!("%{s}%"); + params.push(Box::new(search_pattern.clone())); + params.push(Box::new(search_pattern)); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM template_components {where_sql}"); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + + // 获取分页数据 + let offset = (page - 1) * page_size; + let query_sql = format!( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + {where_sql} + ORDER BY name + LIMIT ? OFFSET ?" + ); + + params.push(Box::new(page_size as i64)); + params.push(Box::new(offset as i64)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let components = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type: ComponentType::from_str(&row.get::<_, String>(2)?) + .unwrap_or(ComponentType::Agent), + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, + }) + })? + .collect::, _>>()?; + + Ok(super::PaginatedResult { + items: components, + total, + page, + page_size, + }) + } +} diff --git a/src-tauri/src/services/template/installer.rs b/src-tauri/src/services/template/installer.rs new file mode 100644 index 000000000..4a1ddad94 --- /dev/null +++ b/src-tauri/src/services/template/installer.rs @@ -0,0 +1,525 @@ +use anyhow::Result; +use rusqlite::{params, Connection}; +use std::fs; + +use super::{ + BatchInstallResult, ComponentDetail, ComponentType, InstalledComponent, TemplateComponent, + TemplateService, +}; + +impl TemplateService { + /// 获取组件详情(含完整内容) + pub async fn get_component(&self, conn: &Connection, id: i64) -> Result { + // 查询组件基本信息 + let component: TemplateComponent = conn.query_row( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + WHERE id = ?1", + params![id], + |row| { + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type: ComponentType::from_str(&row.get::<_, String>(2)?) + .unwrap_or(ComponentType::Agent), + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, + }) + }, + )?; + + // 查询仓库信息 + let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row( + "SELECT owner, name, branch FROM template_repos WHERE id = ?1", + params![component.repo_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + + // 构建 README URL + let readme_url = format!( + "https://github.com/{}/{}/tree/{}/{}", + repo_owner, repo_name, branch, component.path + ); + + // 下载并读取组件内容 + let content = self + .download_component_content(&repo_owner, &repo_name, &branch, &component.path) + .await?; + + Ok(ComponentDetail { + component, + content, + repo_owner, + repo_name, + repo_branch: branch, + readme_url, + }) + } + + /// 下载组件内容 + async fn download_component_content( + &self, + owner: &str, + name: &str, + branch: &str, + path: &str, + ) -> Result { + let url = format!("https://raw.githubusercontent.com/{owner}/{name}/{branch}/{path}"); + + let response = self.client().get(&url).send().await?; + if !response.status().is_success() { + anyhow::bail!("下载组件内容失败: HTTP {}", response.status()); + } + + let content = response.text().await?; + Ok(content) + } + + /// 安装组件到指定应用 + pub async fn install_component( + &self, + conn: &Connection, + id: i64, + app_type: &str, + ) -> Result<()> { + // 获取组件详情 + let detail = self.get_component(conn, id).await?; + + // 根据组件类型执行不同的安装逻辑 + match detail.component.component_type { + ComponentType::Agent => { + self.install_agent(&detail, app_type).await?; + } + ComponentType::Command => { + self.install_command(&detail, app_type).await?; + } + ComponentType::Mcp => { + self.install_mcp(&detail, app_type).await?; + } + ComponentType::Setting => { + self.install_setting(&detail, app_type).await?; + } + ComponentType::Hook => { + self.install_hook(&detail, app_type).await?; + } + ComponentType::Skill => { + self.install_skill(&detail, app_type).await?; + } + } + + // 记录安装状态 + self.record_installation(conn, &detail.component, app_type)?; + + Ok(()) + } + + /// 安装 Agent + async fn install_agent(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let agents_dir = config_dir.join("agents"); + fs::create_dir_all(&agents_dir)?; + + let file_name = format!("{}.md", detail.component.name); + let dest_path = agents_dir.join(&file_name); + + fs::write(&dest_path, &detail.content)?; + log::info!("Agent 已安装: {}", dest_path.display()); + + Ok(()) + } + + /// 安装 Command + async fn install_command(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let commands_dir = config_dir.join("commands"); + fs::create_dir_all(&commands_dir)?; + + let file_name = format!("{}.md", detail.component.name); + let dest_path = commands_dir.join(&file_name); + + fs::write(&dest_path, &detail.content)?; + log::info!("Command 已安装: {}", dest_path.display()); + + Ok(()) + } + + /// 安装 MCP 服务器 + /// MCP 配置保存为独立 JSON 文件到 mcps/ 目录,不会修改原有 .mcp.json + async fn install_mcp(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let mcps_dir = config_dir.join("mcps"); + fs::create_dir_all(&mcps_dir)?; + + // 保存为独立的 JSON 文件(保留原始格式,包含 mcpServers 结构) + let file_name = format!("{}.json", detail.component.name); + let dest_path = mcps_dir.join(&file_name); + + fs::write(&dest_path, &detail.content)?; + log::info!( + "MCP 配置已保存: {} (可手动合并到 .mcp.json)", + dest_path.display() + ); + + Ok(()) + } + + /// 安装 Setting + /// Setting 配置保存为独立 JSON 文件到 settings/ 目录,不会修改原有 settings.json + /// 原始格式包含 permissions 等配置,可手动合并 + async fn install_setting(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let settings_dir = config_dir.join("settings"); + fs::create_dir_all(&settings_dir)?; + + // 保存为独立的 JSON 文件(保留原始格式,包含 permissions 等结构) + let file_name = format!("{}.json", detail.component.name); + let dest_path = settings_dir.join(&file_name); + + fs::write(&dest_path, &detail.content)?; + log::info!( + "Setting 配置已保存: {} (可手动合并到 settings.json)", + dest_path.display() + ); + + Ok(()) + } + + /// 安装 Hook + /// Hook 配置保存为独立 JSON 文件到 hooks/ 目录,不会修改原有 settings.json + /// 原始格式包含 hooks 对象(如 PostToolUse 等),可手动合并 + async fn install_hook(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let hooks_dir = config_dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + // 保存为独立的 JSON 文件(保留原始格式,包含 hooks 结构) + let file_name = format!("{}.json", detail.component.name); + let dest_path = hooks_dir.join(&file_name); + + fs::write(&dest_path, &detail.content)?; + log::info!( + "Hook 配置已保存: {} (可手动合并到 settings.json 的 hooks 字段)", + dest_path.display() + ); + + Ok(()) + } + + /// 安装 Skill + /// Skill 是一个目录结构,包含 SKILL.md 和可能的子目录(如 reference/, scripts/) + /// 使用 GitHub API 递归下载整个目录 + async fn install_skill(&self, detail: &ComponentDetail, app_type: &str) -> Result<()> { + let config_dir = Self::get_app_config_dir(app_type)?; + let skills_dir = config_dir.join("skills"); + fs::create_dir_all(&skills_dir)?; + + let skill_dir = skills_dir.join(&detail.component.name); + fs::create_dir_all(&skill_dir)?; + + // 首先保存 SKILL.md(已下载的内容) + let skill_md = skill_dir.join("SKILL.md"); + fs::write(&skill_md, &detail.content)?; + + // 尝试下载整个 skill 目录的其他文件 + // 构建 GitHub API URL 来获取目录内容 + let api_url = format!( + "https://api.github.com/repos/{}/{}/contents/{}", + detail.repo_owner, + detail.repo_name, + detail.component.path.trim_end_matches("/SKILL.md") + ); + + // 递归下载目录内容 + if let Err(e) = self + .download_skill_directory(&api_url, &skill_dir, &detail.repo_branch) + .await + { + log::warn!("下载 Skill 附加文件失败: {e},仅安装 SKILL.md"); + } + + log::info!("Skill 已安装: {}", skill_dir.display()); + Ok(()) + } + + /// 递归下载 Skill 目录内容 + async fn download_skill_directory( + &self, + api_url: &str, + target_dir: &std::path::Path, + branch: &str, + ) -> Result<()> { + let response = self + .client() + .get(api_url) + .header("Accept", "application/vnd.github.v3+json") + .header("User-Agent", "cc-switch") + .query(&[("ref", branch)]) + .send() + .await?; + + if !response.status().is_success() { + anyhow::bail!("GitHub API 请求失败: {}", response.status()); + } + + let contents: Vec = response.json().await?; + + for item in contents { + let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let item_name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); + + // 跳过 SKILL.md(已经下载) + if item_name == "SKILL.md" { + continue; + } + + if item_type == "file" { + // 下载文件 + if let Some(download_url) = item.get("download_url").and_then(|v| v.as_str()) { + let file_response = self.client().get(download_url).send().await?; + if file_response.status().is_success() { + let content = file_response.text().await?; + let file_path = target_dir.join(item_name); + fs::write(&file_path, &content)?; + log::debug!("下载文件: {}", file_path.display()); + } + } + } else if item_type == "dir" { + // 递归下载子目录 + if let Some(sub_url) = item.get("url").and_then(|v| v.as_str()) { + let sub_dir = target_dir.join(item_name); + fs::create_dir_all(&sub_dir)?; + // 递归调用,使用 Box::pin 处理异步递归 + Box::pin(self.download_skill_directory(sub_url, &sub_dir, branch)).await?; + } + } + } + + Ok(()) + } + + /// 记录安装状态 + fn record_installation( + &self, + conn: &Connection, + component: &TemplateComponent, + app_type: &str, + ) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO installed_components (component_id, component_type, name, path, app_type) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + component.id, + component.component_type.as_str(), + &component.name, + &component.path, + app_type + ], + )?; + + Ok(()) + } + + /// 卸载组件 + pub fn uninstall_component(&self, conn: &Connection, id: i64, app_type: &str) -> Result<()> { + // 查询组件信息 + let component: TemplateComponent = conn.query_row( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + WHERE id = ?1", + params![id], + |row| { + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type: ComponentType::from_str(&row.get::<_, String>(2)?) + .unwrap_or(ComponentType::Agent), + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, + }) + }, + )?; + + // 删除文件 + match component.component_type { + ComponentType::Agent => { + let config_dir = Self::get_app_config_dir(app_type)?; + let file_path = config_dir + .join("agents") + .join(format!("{}.md", component.name)); + if file_path.exists() { + fs::remove_file(&file_path)?; + } + } + ComponentType::Command => { + let config_dir = Self::get_app_config_dir(app_type)?; + let file_path = config_dir + .join("commands") + .join(format!("{}.md", component.name)); + if file_path.exists() { + fs::remove_file(&file_path)?; + } + } + ComponentType::Skill => { + let config_dir = Self::get_app_config_dir(app_type)?; + let skill_dir = config_dir.join("skills").join(&component.name); + if skill_dir.exists() { + fs::remove_dir_all(&skill_dir)?; + } + } + ComponentType::Mcp => { + let config_dir = Self::get_app_config_dir(app_type)?; + let file_path = config_dir + .join("mcps") + .join(format!("{}.json", component.name)); + if file_path.exists() { + fs::remove_file(&file_path)?; + } + } + ComponentType::Setting => { + let config_dir = Self::get_app_config_dir(app_type)?; + let file_path = config_dir + .join("settings") + .join(format!("{}.json", component.name)); + if file_path.exists() { + fs::remove_file(&file_path)?; + } + } + ComponentType::Hook => { + let config_dir = Self::get_app_config_dir(app_type)?; + let file_path = config_dir + .join("hooks") + .join(format!("{}.json", component.name)); + if file_path.exists() { + fs::remove_file(&file_path)?; + } + } + } + + // 删除安装记录 + conn.execute( + "DELETE FROM installed_components + WHERE component_id = ?1 AND app_type = ?2", + params![id, app_type], + )?; + + log::info!("组件已卸载: {}", component.name); + Ok(()) + } + + /// 批量安装组件 + pub async fn batch_install( + &self, + conn: &Connection, + ids: Vec, + app_type: &str, + ) -> Result { + let mut success = Vec::new(); + let mut failed = Vec::new(); + + for id in ids { + match self.install_component(conn, id, app_type).await { + Ok(_) => success.push(id), + Err(e) => failed.push((id, e.to_string())), + } + } + + Ok(BatchInstallResult { success, failed }) + } + + /// 列出已安装的组件 + #[allow(dead_code)] + pub fn list_installed( + &self, + conn: &Connection, + app_type: Option<&str>, + ) -> Result> { + let (sql, params): (String, Vec>) = if let Some(at) = app_type { + ( + "SELECT id, component_id, component_type, name, path, app_type, installed_at + FROM installed_components + WHERE app_type = ? + ORDER BY installed_at DESC" + .to_string(), + vec![Box::new(at.to_string())], + ) + } else { + ( + "SELECT id, component_id, component_type, name, path, app_type, installed_at + FROM installed_components + ORDER BY installed_at DESC" + .to_string(), + vec![], + ) + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&sql)?; + let components = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(InstalledComponent { + id: Some(row.get(0)?), + component_id: row.get(1)?, + component_type: ComponentType::from_str(&row.get::<_, String>(2)?) + .unwrap_or(ComponentType::Agent), + name: row.get(3)?, + path: row.get(4)?, + app_type: row.get(5)?, + installed_at: row + .get::<_, String>(6)? + .parse() + .unwrap_or_else(|_| chrono::Utc::now()), + }) + })? + .collect::, _>>()?; + + Ok(components) + } + + /// 预览组件内容(仅获取内容,不进行安装) + pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result { + // 查询组件基本信息 + let component: TemplateComponent = conn.query_row( + "SELECT id, repo_id, component_type, category, name, path, description, content_hash + FROM template_components + WHERE id = ?1", + params![id], + |row| { + Ok(TemplateComponent { + id: Some(row.get(0)?), + repo_id: row.get(1)?, + component_type: ComponentType::from_str(&row.get::<_, String>(2)?) + .unwrap_or(ComponentType::Agent), + category: row.get(3)?, + name: row.get(4)?, + path: row.get(5)?, + description: row.get(6)?, + content_hash: row.get(7)?, + installed: false, + }) + }, + )?; + + // 查询仓库信息 + let (repo_owner, repo_name, branch): (String, String, String) = conn.query_row( + "SELECT owner, name, branch FROM template_repos WHERE id = ?1", + params![component.repo_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + + // 下载并读取组件内容 + let content = self + .download_component_content(&repo_owner, &repo_name, &branch, &component.path) + .await?; + + Ok(content) + } +} diff --git a/src-tauri/src/services/template/mod.rs b/src-tauri/src/services/template/mod.rs new file mode 100644 index 000000000..34ccd92e7 --- /dev/null +++ b/src-tauri/src/services/template/mod.rs @@ -0,0 +1,357 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +pub mod adapters; +pub mod index; +pub mod installer; +pub mod repo; + +#[allow(unused_imports)] +pub use adapters::{create_adapter, AppAdapter}; + +/// 组件类型枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ComponentType { + Agent, + Command, + Mcp, + Setting, + Hook, + Skill, +} + +impl ComponentType { + pub fn as_str(&self) -> &str { + match self { + ComponentType::Agent => "agent", + ComponentType::Command => "command", + ComponentType::Mcp => "mcp", + ComponentType::Setting => "setting", + ComponentType::Hook => "hook", + ComponentType::Skill => "skill", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "agent" => Some(ComponentType::Agent), + "command" => Some(ComponentType::Command), + "mcp" => Some(ComponentType::Mcp), + "setting" => Some(ComponentType::Setting), + "hook" => Some(ComponentType::Hook), + "skill" => Some(ComponentType::Skill), + _ => None, + } + } +} + +/// 模板仓库 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateRepo { + pub id: Option, + pub owner: String, + pub name: String, + pub branch: String, + pub enabled: bool, + #[serde(rename = "createdAt")] + pub created_at: Option>, + #[serde(rename = "updatedAt")] + pub updated_at: Option>, +} + +impl TemplateRepo { + pub fn new(owner: String, name: String, branch: String) -> Self { + Self { + id: None, + owner, + name, + branch, + enabled: true, + created_at: None, + updated_at: None, + } + } +} + +/// 模板组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateComponent { + pub id: Option, + #[serde(rename = "repoId")] + pub repo_id: i64, + #[serde(rename = "componentType")] + pub component_type: ComponentType, + pub category: Option, + pub name: String, + pub path: String, + pub description: Option, + #[serde(rename = "contentHash")] + pub content_hash: Option, + /// 是否已安装(前端展示用,需要在查询时填充) + pub installed: bool, +} + +/// 组件详情(含完整内容) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComponentDetail { + #[serde(flatten)] + pub component: TemplateComponent, + /// 完整文件内容 + pub content: String, + /// 仓库所有者 + #[serde(rename = "repoOwner")] + pub repo_owner: String, + /// 仓库名称 + #[serde(rename = "repoName")] + pub repo_name: String, + /// 仓库分支 + #[serde(rename = "repoBranch")] + pub repo_branch: String, + /// GitHub README URL + #[serde(rename = "readmeUrl")] + pub readme_url: String, +} + +/// 组件元数据(从文件 front matter 解析) +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct ComponentMetadata { + pub name: Option, + pub description: Option, + /// Agent 专用 - 工具列表 + pub tools: Option, + /// Agent 专用 - 模型名称 + pub model: Option, +} + +/// 分页结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginatedResult { + pub items: Vec, + pub total: i64, + pub page: u32, + #[serde(rename = "pageSize")] + pub page_size: u32, +} + +/// 批量安装结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchInstallResult { + pub success: Vec, + pub failed: Vec<(i64, String)>, +} + +/// 已安装组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledComponent { + pub id: Option, + #[serde(rename = "componentId")] + pub component_id: Option, + #[serde(rename = "componentType")] + pub component_type: ComponentType, + pub name: String, + pub path: String, + #[serde(rename = "appType")] + pub app_type: String, + #[serde(rename = "installedAt")] + pub installed_at: DateTime, +} + +/// 市场组合项(plugin 中的单个组件) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceBundleItem { + pub name: String, + pub path: String, + #[serde(rename = "componentType")] + pub component_type: String, +} + +/// 市场组合 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceBundle { + pub id: String, + pub name: String, + pub description: String, + pub category: String, + pub components: Vec, +} + +/// Template 服务 +pub struct TemplateService { + http_client: Client, +} + +impl TemplateService { + pub fn new() -> Result { + Ok(Self { + http_client: Client::builder() + .user_agent("cc-switch") + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("创建 HTTP 客户端失败")?, + }) + } + + /// 获取 HTTP 客户端 + pub fn client(&self) -> &Client { + &self.http_client + } + + /// 获取应用配置目录 + pub fn get_app_config_dir(app_type: &str) -> Result { + let home = dirs::home_dir().context("无法获取用户主目录")?; + + let dir = match app_type.to_lowercase().as_str() { + "claude" => { + // 检查是否有自定义 Claude 配置目录 + if let Some(custom) = crate::settings::get_claude_override_dir() { + custom + } else { + home.join(".claude") + } + } + "codex" => { + // 检查是否有自定义 Codex 配置目录 + if let Some(custom) = crate::settings::get_codex_override_dir() { + custom + } else { + home.join(".codex") + } + } + "gemini" => { + // 检查是否有自定义 Gemini 配置目录 + if let Some(custom) = crate::settings::get_gemini_override_dir() { + custom + } else { + home.join(".gemini") + } + } + _ => anyhow::bail!("不支持的应用类型: {app_type}"), + }; + + Ok(dir) + } + + /// 从 components.json 获取市场组合 + pub async fn fetch_marketplace_bundles( + &self, + conn: &rusqlite::Connection, + ) -> Result> { + // 获取启用的仓库 + let repos = self.list_enabled_repos(conn)?; + if repos.is_empty() { + return Ok(vec![]); + } + + let mut bundles = Vec::new(); + + for repo in repos { + // 尝试多个可能的路径 + let urls = [ + format!( + "https://raw.githubusercontent.com/{}/{}/{}/components.json", + repo.owner, repo.name, repo.branch + ), + format!( + "https://raw.githubusercontent.com/{}/{}/{}/docs/components.json", + repo.owner, repo.name, repo.branch + ), + ]; + + for url in urls { + match self.http_client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(json) = resp.json::().await { + // 解析 marketplace.plugins(完整插件包) + if let Some(marketplace) = json.get("marketplace") { + if let Some(plugins) = marketplace.get("plugins") { + if let Some(arr) = plugins.as_array() { + for plugin in arr { + let name = plugin + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let description = plugin + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // 提取各类型组件路径 + let mut components = Vec::new(); + let component_types = [ + "agents", "commands", "mcps", "settings", "hooks", + "skills", + ]; + + for comp_type in component_types { + if let Some(paths) = + plugin.get(comp_type).and_then(|v| v.as_array()) + { + // 单数形式的类型名 + let singular_type = match comp_type { + "agents" => "agent", + "commands" => "command", + "mcps" => "mcp", + "settings" => "setting", + "hooks" => "hook", + "skills" => "skill", + _ => comp_type, + }; + + for path_val in paths { + if let Some(path) = path_val.as_str() { + // 从路径提取组件名(文件名不含扩展名) + let comp_name = + std::path::Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + components.push( + MarketplaceBundleItem { + name: comp_name, + path: path.to_string(), + component_type: singular_type + .to_string(), + }, + ); + } + } + } + } + + if !components.is_empty() { + bundles.push(MarketplaceBundle { + id: format!("{}-plugin-{}", repo.name, name), + name: name.to_string(), + description: description.to_string(), + category: "plugin".to_string(), + components, + }); + } + } + } + } + } + } + break; // 成功获取后跳出 URL 循环 + } + _ => continue, + } + } + } + + Ok(bundles) + } +} + +impl Default for TemplateService { + fn default() -> Self { + Self::new().expect("创建 TemplateService 失败") + } +} diff --git a/src-tauri/src/services/template/repo.rs b/src-tauri/src/services/template/repo.rs new file mode 100644 index 000000000..6e8b1e460 --- /dev/null +++ b/src-tauri/src/services/template/repo.rs @@ -0,0 +1,239 @@ +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; + +use super::{TemplateRepo, TemplateService}; + +#[allow(dead_code)] +impl TemplateService { + /// 列出所有模板仓库 + pub fn list_repos(&self, conn: &Connection) -> Result> { + let mut stmt = conn + .prepare( + "SELECT id, owner, name, branch, enabled, created_at, updated_at + FROM template_repos + ORDER BY created_at DESC", + ) + .context("准备查询模板仓库语句失败")?; + + let repos = stmt + .query_map([], |row| { + Ok(TemplateRepo { + id: Some(row.get(0)?), + owner: row.get(1)?, + name: row.get(2)?, + branch: row.get(3)?, + enabled: row.get::<_, i64>(4)? != 0, + created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()), + updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()), + }) + }) + .context("查询模板仓库失败")? + .collect::, _>>() + .context("收集模板仓库结果失败")?; + + Ok(repos) + } + + /// 添加模板仓库 + pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result { + // 检查是否已存在 + let existing: Option = conn + .query_row( + "SELECT id FROM template_repos WHERE owner = ?1 AND name = ?2", + params![&repo.owner, &repo.name], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing { + // 更新已存在的仓库 + conn.execute( + "UPDATE template_repos + SET branch = ?1, enabled = ?2, updated_at = CURRENT_TIMESTAMP + WHERE id = ?3", + params![&repo.branch, repo.enabled as i64, id], + ) + .context("更新模板仓库失败")?; + Ok(id) + } else { + // 插入新仓库 + conn.execute( + "INSERT INTO template_repos (owner, name, branch, enabled) + VALUES (?1, ?2, ?3, ?4)", + params![&repo.owner, &repo.name, &repo.branch, repo.enabled as i64], + ) + .context("插入模板仓库失败")?; + Ok(conn.last_insert_rowid()) + } + } + + /// 删除模板仓库 + pub fn remove_repo(&self, conn: &Connection, id: i64) -> Result<()> { + let rows = conn + .execute("DELETE FROM template_repos WHERE id = ?1", params![id]) + .context("删除模板仓库失败")?; + + if rows == 0 { + anyhow::bail!("模板仓库不存在: id={id}"); + } + + Ok(()) + } + + /// 切换仓库启用状态 + pub fn toggle_repo_enabled(&self, conn: &Connection, id: i64) -> Result { + // 获取当前状态 + let enabled: i64 = conn + .query_row( + "SELECT enabled FROM template_repos WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .context("查询仓库状态失败")?; + + let new_enabled = enabled == 0; + + // 更新状态 + conn.execute( + "UPDATE template_repos + SET enabled = ?1, updated_at = CURRENT_TIMESTAMP + WHERE id = ?2", + params![new_enabled as i64, id], + ) + .context("更新仓库状态失败")?; + + Ok(new_enabled) + } + + /// 获取单个仓库 + pub fn get_repo(&self, conn: &Connection, id: i64) -> Result { + conn.query_row( + "SELECT id, owner, name, branch, enabled, created_at, updated_at + FROM template_repos + WHERE id = ?1", + params![id], + |row| { + Ok(TemplateRepo { + id: Some(row.get(0)?), + owner: row.get(1)?, + name: row.get(2)?, + branch: row.get(3)?, + enabled: row.get::<_, i64>(4)? != 0, + created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()), + updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()), + }) + }, + ) + .context(format!("查询模板仓库失败: id={id}")) + } + + /// 获取启用的仓库列表 + pub fn list_enabled_repos(&self, conn: &Connection) -> Result> { + let mut stmt = conn + .prepare( + "SELECT id, owner, name, branch, enabled, created_at, updated_at + FROM template_repos + WHERE enabled = 1 + ORDER BY created_at DESC", + ) + .context("准备查询启用仓库语句失败")?; + + let repos = stmt + .query_map([], |row| { + Ok(TemplateRepo { + id: Some(row.get(0)?), + owner: row.get(1)?, + name: row.get(2)?, + branch: row.get(3)?, + enabled: row.get::<_, i64>(4)? != 0, + created_at: row.get::<_, String>(5).ok().and_then(|s| s.parse().ok()), + updated_at: row.get::<_, String>(6).ok().and_then(|s| s.parse().ok()), + }) + }) + .context("查询启用仓库失败")? + .collect::, _>>() + .context("收集启用仓库结果失败")?; + + Ok(repos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE IF NOT EXISTS template_repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner, name) + )", + [], + ) + .unwrap(); + conn + } + + #[test] + fn test_add_and_list_repos() { + let conn = setup_db(); + let service = TemplateService::new().unwrap(); + + // 添加仓库 + let repo = TemplateRepo::new( + "yovinchen".to_string(), + "claude-code-templates".to_string(), + "main".to_string(), + ); + let id = service.add_repo(&conn, repo).unwrap(); + assert!(id > 0); + + // 列出仓库 + let repos = service.list_repos(&conn).unwrap(); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].owner, "yovinchen"); + assert_eq!(repos[0].name, "claude-code-templates"); + } + + #[test] + fn test_toggle_repo_enabled() { + let conn = setup_db(); + let service = TemplateService::new().unwrap(); + + // 添加仓库 + let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string()); + let id = service.add_repo(&conn, repo).unwrap(); + + // 切换状态 + let enabled = service.toggle_repo_enabled(&conn, id).unwrap(); + assert!(!enabled); + + let enabled = service.toggle_repo_enabled(&conn, id).unwrap(); + assert!(enabled); + } + + #[test] + fn test_remove_repo() { + let conn = setup_db(); + let service = TemplateService::new().unwrap(); + + // 添加仓库 + let repo = TemplateRepo::new("test".to_string(), "repo".to_string(), "main".to_string()); + let id = service.add_repo(&conn, repo).unwrap(); + + // 删除仓库 + service.remove_repo(&conn, id).unwrap(); + + // 验证已删除 + let repos = service.list_repos(&conn).unwrap(); + assert_eq!(repos.len(), 0); + } +} diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 5afa0ec5c..13b41c252 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -860,9 +860,7 @@ mod tests { } else { assert!( result.is_err(), - "应该不匹配的URL被允许: base_url={}, request_url={}", - base_url, - request_url + "应该不匹配的URL被允许: base_url={base_url}, request_url={request_url}" ); } } diff --git a/src/App.tsx b/src/App.tsx index cb418c438..e57d9eaa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Wrench, Server, RefreshCw, + Package, } from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -41,9 +42,17 @@ import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; +import { TemplatesPage } from "@/components/templates/TemplatesPage"; import { Button } from "@/components/ui/button"; -type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; +type View = + | "providers" + | "settings" + | "prompts" + | "skills" + | "mcp" + | "agents" + | "templates"; function App() { const { t } = useTranslation(); @@ -312,6 +321,8 @@ function App() { ); case "agents": return setCurrentView("providers")} />; + case "templates": + return ; default: return (
@@ -410,6 +421,7 @@ function App() { {currentView === "skills" && t("skills.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "agents" && t("agents.title")} + {currentView === "templates" && t("templates.title")}
) : ( @@ -541,6 +553,15 @@ function App() { > + + {status && status.installedCount > 0 && ( + + )} + {!status?.installed && ( + + )} + + + + ); +} diff --git a/src/components/templates/BundleList.tsx b/src/components/templates/BundleList.tsx new file mode 100644 index 000000000..6d2c801d9 --- /dev/null +++ b/src/components/templates/BundleList.tsx @@ -0,0 +1,399 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Download, Loader2, Package, Trash2, FileText } from "lucide-react"; +import { toast } from "sonner"; +import { + useMarketplaceBundles, + useBatchInstallComponents, +} from "@/lib/query/template"; +import { templateApi } from "@/lib/api/template"; +import { BundleDetail } from "./BundleDetail"; +import type { MarketplaceBundle, ComponentType } from "@/types/template"; +import type { AppType } from "@/lib/api/config"; + +// 组件类型图标映射 +const componentTypeIcons: Record = { + agent: "🤖", + command: "⚡", + mcp: "🔌", + setting: "⚙️", + hook: "🪝", + skill: "💡", +}; + +interface BundleInstallStatus { + installed: boolean; + installedIds: number[]; + totalCount: number; + installedCount: number; +} + +interface BundleListProps { + selectedApp: AppType; +} + +// 统计组件类型数量 +function getComponentTypeCounts(components: MarketplaceBundle["components"]) { + const counts: Record = {}; + for (const comp of components) { + const type = comp.componentType; + counts[type] = (counts[type] || 0) + 1; + } + return counts; +} + +export function BundleList({ selectedApp }: BundleListProps) { + const { t } = useTranslation(); + const [installingBundle, setInstallingBundle] = useState(null); + const [uninstallingBundle, setUninstallingBundle] = useState( + null, + ); + const [bundleStatuses, setBundleStatuses] = useState< + Record + >({}); + const [detailBundle, setDetailBundle] = useState( + null, + ); + + const { data: bundles = [], isLoading } = useMarketplaceBundles(); + const batchInstallMutation = useBatchInstallComponents(); + + // 检查组合安装状态 + const checkBundleStatus = useCallback( + async (bundle: MarketplaceBundle): Promise => { + const componentsByType = bundle.components.reduce( + (acc, comp) => { + const type = comp.componentType; + if (!acc[type]) acc[type] = []; + acc[type].push(comp.name.toLowerCase()); + return acc; + }, + {} as Record, + ); + + const installedIds: number[] = []; + let totalMatched = 0; + + for (const [componentType, names] of Object.entries(componentsByType)) { + const componentsData = await templateApi.listTemplateComponents({ + componentType: componentType as ComponentType, + pageSize: 1000, + appType: selectedApp, + }); + + for (const comp of componentsData.items) { + if (names.includes(comp.name.toLowerCase())) { + totalMatched++; + if (comp.installed && comp.id !== null) { + installedIds.push(comp.id); + } + } + } + } + + return { + installed: + installedIds.length > 0 && installedIds.length === totalMatched, + installedIds, + totalCount: totalMatched, + installedCount: installedIds.length, + }; + }, + [selectedApp], + ); + + // 加载所有组合的安装状态 + useEffect(() => { + const loadStatuses = async () => { + const statuses: Record = {}; + for (const bundle of bundles) { + statuses[bundle.id] = await checkBundleStatus(bundle); + } + setBundleStatuses(statuses); + }; + if (bundles.length > 0) { + loadStatuses(); + } + }, [bundles, checkBundleStatus]); + + const handleInstallBundle = async (bundle: MarketplaceBundle) => { + setInstallingBundle(bundle.id); + try { + // 按组件类型分组 + const componentsByType = bundle.components.reduce( + (acc, comp) => { + const type = comp.componentType; + if (!acc[type]) acc[type] = []; + acc[type].push(comp.name.toLowerCase()); + return acc; + }, + {} as Record, + ); + + // 收集所有匹配的组件 ID + const matchedIds: number[] = []; + + for (const [componentType, names] of Object.entries(componentsByType)) { + const componentsData = await templateApi.listTemplateComponents({ + componentType: componentType as ComponentType, + pageSize: 1000, + }); + + const ids = componentsData.items + .filter((c) => names.includes(c.name.toLowerCase())) + .map((c) => c.id) + .filter((id): id is number => id !== null); + + matchedIds.push(...ids); + } + + if (matchedIds.length === 0) { + toast.warning( + t("templates.bundle.noMatch", { + defaultValue: "未找到匹配的组件", + }), + ); + return; + } + + const result = await batchInstallMutation.mutateAsync({ + ids: matchedIds, + appType: selectedApp, + }); + + toast.success( + t("templates.bundle.installSuccess", { + count: result.success.length, + defaultValue: `已安装 ${result.success.length} 个组件`, + }), + ); + + if (result.failed.length > 0) { + toast.warning( + t("templates.bundle.partialFail", { + count: result.failed.length, + defaultValue: `${result.failed.length} 个组件安装失败`, + }), + ); + } + + // 刷新安装状态 + const newStatus = await checkBundleStatus(bundle); + setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + t("templates.bundle.installFailed", { defaultValue: "安装组合失败" }), + { description: errorMessage, duration: 8000 }, + ); + } finally { + setInstallingBundle(null); + } + }; + + const handleUninstallBundle = async (bundle: MarketplaceBundle) => { + const status = bundleStatuses[bundle.id]; + if (!status || status.installedIds.length === 0) return; + + setUninstallingBundle(bundle.id); + try { + let successCount = 0; + let failCount = 0; + + for (const id of status.installedIds) { + try { + await templateApi.uninstallTemplateComponent(id, selectedApp); + successCount++; + } catch { + failCount++; + } + } + + if (successCount > 0) { + toast.success( + t("templates.bundle.uninstallSuccess", { + count: successCount, + defaultValue: `已卸载 ${successCount} 个组件`, + }), + ); + } + + if (failCount > 0) { + toast.warning( + t("templates.bundle.uninstallPartialFail", { + count: failCount, + defaultValue: `${failCount} 个组件卸载失败`, + }), + ); + } + + // 刷新安装状态 + const newStatus = await checkBundleStatus(bundle); + setBundleStatuses((prev) => ({ ...prev, [bundle.id]: newStatus })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + t("templates.bundle.uninstallFailed", { defaultValue: "卸载组合失败" }), + { description: errorMessage, duration: 8000 }, + ); + } finally { + setUninstallingBundle(null); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (bundles.length === 0) { + return ( +
+ +

+ {t("templates.bundle.empty", { defaultValue: "暂无组合" })} +

+

+ {t("templates.bundle.emptyDescription", { + defaultValue: "请添加包含 components.json 的模板仓库", + })} +

+
+ ); + } + + return ( + <> +
+ {bundles.map((bundle) => { + const typeCounts = getComponentTypeCounts(bundle.components); + const status = bundleStatuses[bundle.id]; + + return ( +
setDetailBundle(bundle)} + > +
+ + {/* 头部 */} +
+
+
+ 📦 +
+

+ {bundle.name} +

+
+ {status?.installed && ( + + {t("templates.installed", { defaultValue: "已安装" })} + + )} +
+ + {/* 描述 */} +

+ {bundle.description || + t("templates.noDescription", { defaultValue: "暂无描述" })} +

+ + {/* 组件类型统计 */} +
+ {Object.entries(typeCounts).map(([type, count]) => ( + + + {componentTypeIcons[type] || "📦"} + + {type} {count} + + ))} +
+ + {/* 底部操作栏 */} +
+ + + {status && status.installedCount > 0 && ( + + )} + {!status?.installed && ( + + )} +
+
+ ); + })} +
+ + {/* 详情弹窗 */} + {detailBundle && ( + setDetailBundle(null)} + onInstall={() => handleInstallBundle(detailBundle)} + onUninstall={() => handleUninstallBundle(detailBundle)} + installing={installingBundle === detailBundle.id} + uninstalling={uninstallingBundle === detailBundle.id} + /> + )} + + ); +} diff --git a/src/components/templates/CategoryFilter.tsx b/src/components/templates/CategoryFilter.tsx new file mode 100644 index 000000000..2e93fc62a --- /dev/null +++ b/src/components/templates/CategoryFilter.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +interface CategoryFilterProps { + categories: string[]; + selectedCategory?: string; + onSelectCategory: (category: string | undefined) => void; +} + +export function CategoryFilter({ + categories, + selectedCategory, + onSelectCategory, +}: CategoryFilterProps) { + const { t } = useTranslation(); + + return ( +
+

+ {t("templates.category.title", { defaultValue: "分类" })} +

+
+
+ {/* 全部选项 */} + + + {/* 分类列表 */} + {categories.length > 0 && ( + <> +
+ {categories.map((category) => ( + + ))} + + )} + + {/* 无分类提示 */} + {categories.length === 0 && selectedCategory === undefined && ( +

+ {t("templates.category.empty", { defaultValue: "暂无分类" })} +

+ )} +
+
+
+ ); +} diff --git a/src/components/templates/ComponentCard.tsx b/src/components/templates/ComponentCard.tsx new file mode 100644 index 000000000..80c080754 --- /dev/null +++ b/src/components/templates/ComponentCard.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Download, Trash2, Loader2, FileText } from "lucide-react"; +import type { TemplateComponent } from "@/types/template"; + +interface ComponentCardProps { + component: TemplateComponent; + onInstall: () => Promise; + onUninstall: () => Promise; + onViewDetail: () => void; +} + +// 组件类型图标映射 +const componentTypeIcons: Record = { + agent: "🤖", + command: "⚡", + mcp: "🔌", + setting: "⚙️", + hook: "🪝", + skill: "💡", +}; + +export function ComponentCard({ + component, + onInstall, + onUninstall, + onViewDetail, +}: ComponentCardProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleInstall = async () => { + setLoading(true); + try { + await onInstall(); + } finally { + setLoading(false); + } + }; + + const handleUninstall = async () => { + setLoading(true); + try { + await onUninstall(); + } finally { + setLoading(false); + } + }; + + const typeIcon = componentTypeIcons[component.componentType] || "📦"; + + return ( + +
+ +
+ +
+
+
+ {typeIcon} +
+ + {component.name} + + {component.category && ( + + + {component.category} + + + )} +
+ {component.installed && ( + + {t("templates.installed", { defaultValue: "已安装" })} + + )} +
+
+ + +

+ {component.description || + t("templates.noDescription", { defaultValue: "暂无描述" })} +

+
+
+ + + + + {component.installed ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/templates/ComponentDetail.tsx b/src/components/templates/ComponentDetail.tsx new file mode 100644 index 000000000..349bc73b2 --- /dev/null +++ b/src/components/templates/ComponentDetail.tsx @@ -0,0 +1,314 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Download, + Trash2, + Loader2, + ExternalLink, + FileCode, + FolderGit2, + Tag, + Clock, +} from "lucide-react"; +import { settingsApi } from "@/lib/api"; +import { + useTemplateComponent, + useComponentPreview, +} from "@/lib/query/template"; +import type { AppType } from "@/lib/api/config"; + +interface ComponentDetailProps { + componentId: number; + selectedApp: AppType; + onClose: () => void; + onInstall: (id: number, name: string) => Promise; + onUninstall: (id: number, name: string) => Promise; +} + +// 组件类型图标映射 +const componentTypeIcons: Record = { + agent: "🤖", + command: "⚡", + mcp: "🔌", + setting: "⚙️", + hook: "🪝", + skill: "💡", +}; + +export function ComponentDetail({ + componentId, + onClose, + onInstall, + onUninstall, +}: ComponentDetailProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const { data: component, isLoading: componentLoading } = + useTemplateComponent(componentId); + const { data: preview, isLoading: previewLoading } = + useComponentPreview(componentId); + + const handleInstall = async () => { + if (!component || component.id === null) return; + setLoading(true); + try { + await onInstall(component.id, component.name); + } finally { + setLoading(false); + } + }; + + const handleUninstall = async () => { + if (!component || component.id === null) return; + setLoading(true); + try { + await onUninstall(component.id, component.name); + } finally { + setLoading(false); + } + }; + + const handleOpenGithub = async () => { + if (component?.readmeUrl) { + try { + await settingsApi.openExternal(component.readmeUrl); + } catch (error) { + console.error("Failed to open URL:", error); + } + } + }; + + if (componentLoading || !component) { + return ( + + +
+ +
+
+
+ ); + } + + const typeIcon = componentTypeIcons[component.componentType] || "📦"; + + return ( + + + +
+
+
+ {typeIcon} + + {t(`templates.type.${component.componentType}`, { + defaultValue: component.componentType, + })} + + {component.category && ( + + {component.category} + + )} +
+ {component.name} + + {component.description || + t("templates.noDescription", { defaultValue: "暂无描述" })} + +
+ {component.installed && ( + + {t("templates.installed", { defaultValue: "已安装" })} + + )} +
+
+ +
+ + + + {t("templates.detail.info", { defaultValue: "信息" })} + + + + {t("templates.detail.preview", { defaultValue: "预览" })} + + + + +
+ {/* 类型 */} +
+
+ +
+
+

+ {t("templates.detail.type", { defaultValue: "类型" })} +

+

+ {t(`templates.type.${component.componentType}`, { + defaultValue: component.componentType, + })} +

+
+
+ + {/* 分类 */} + {component.category && ( +
+
+ +
+
+

+ {t("templates.detail.category", { + defaultValue: "分类", + })} +

+

+ {component.category} +

+
+
+ )} + + {/* 仓库 */} +
+
+ +
+
+

+ {t("templates.detail.repository", { + defaultValue: "仓库", + })} +

+
+

+ {component.repoOwner}/{component.repoName} + {component.repoBranch && + component.repoBranch !== "main" && + ` (${component.repoBranch})`} +

+ {component.readmeUrl && ( + + )} +
+
+
+ + {/* 更新时间 */} + {component.updatedAt && ( +
+
+ +
+
+

+ {t("templates.detail.updatedAt", { + defaultValue: "更新时间", + })} +

+

+ {new Date(component.updatedAt).toLocaleString()} +

+
+
+ )} +
+ + {/* 路径 */} +
+

+ {t("templates.detail.path", { defaultValue: "路径" })} +

+

+ {component.path} +

+
+
+ + + {previewLoading ? ( +
+ +
+ ) : preview ? ( +
+                  {preview}
+                
+ ) : ( +

+ {t("templates.detail.noPreview", { + defaultValue: "无法预览内容", + })} +

+ )} +
+
+
+ + + + {component.installed ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/components/templates/RepoManager.tsx b/src/components/templates/RepoManager.tsx new file mode 100644 index 000000000..17919a52e --- /dev/null +++ b/src/components/templates/RepoManager.tsx @@ -0,0 +1,294 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Trash2, ExternalLink, Plus, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { settingsApi } from "@/lib/api"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { + useTemplateRepos, + useAddTemplateRepo, + useRemoveTemplateRepo, + useToggleTemplateRepo, +} from "@/lib/query/template"; + +interface RepoManagerProps { + onClose: () => void; +} + +export function RepoManager({ onClose }: RepoManagerProps) { + const { t } = useTranslation(); + const [repoUrl, setRepoUrl] = useState(""); + const [branch, setBranch] = useState(""); + const [error, setError] = useState(""); + + const { data: repos = [], isLoading } = useTemplateRepos(); + const addRepoMutation = useAddTemplateRepo(); + const removeRepoMutation = useRemoveTemplateRepo(); + const toggleRepoMutation = useToggleTemplateRepo(); + + const parseRepoUrl = ( + url: string, + ): { owner: string; name: string } | null => { + let cleaned = url.trim(); + cleaned = cleaned.replace(/^https?:\/\/github\.com\//, ""); + cleaned = cleaned.replace(/\.git$/, ""); + + const parts = cleaned.split("/"); + if (parts.length === 2 && parts[0] && parts[1]) { + return { owner: parts[0], name: parts[1] }; + } + + return null; + }; + + const handleAdd = async () => { + setError(""); + + const parsed = parseRepoUrl(repoUrl); + if (!parsed) { + setError( + t("templates.repo.invalidUrl", { + defaultValue: "仓库地址格式不正确", + }), + ); + return; + } + + try { + await addRepoMutation.mutateAsync({ + owner: parsed.owner, + name: parsed.name, + branch: branch || "main", + }); + + toast.success( + t("templates.repo.addSuccess", { + owner: parsed.owner, + name: parsed.name, + defaultValue: `已添加仓库 ${parsed.owner}/${parsed.name}`, + }), + ); + + setRepoUrl(""); + setBranch(""); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + setError( + t("templates.repo.addFailed", { + defaultValue: "添加仓库失败", + }), + ); + toast.error( + t("templates.repo.addFailed", { defaultValue: "添加仓库失败" }), + { + description: errorMessage, + duration: 8000, + }, + ); + } + }; + + const handleRemove = async (id: number, owner: string, name: string) => { + try { + await removeRepoMutation.mutateAsync(id); + toast.success( + t("templates.repo.removeSuccess", { + owner, + name, + defaultValue: `已移除仓库 ${owner}/${name}`, + }), + ); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + toast.error( + t("templates.repo.removeFailed", { defaultValue: "移除仓库失败" }), + { + description: errorMessage, + duration: 8000, + }, + ); + } + }; + + const handleToggle = async (id: number, enabled: boolean) => { + try { + await toggleRepoMutation.mutateAsync({ id, enabled }); + toast.success( + enabled + ? t("templates.repo.enableSuccess", { defaultValue: "已启用仓库" }) + : t("templates.repo.disableSuccess", { defaultValue: "已禁用仓库" }), + ); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + toast.error( + t("templates.repo.toggleFailed", { + defaultValue: "切换仓库状态失败", + }), + { + description: errorMessage, + duration: 8000, + }, + ); + } + }; + + const handleOpenRepo = async (owner: string, name: string) => { + try { + await settingsApi.openExternal(`https://github.com/${owner}/${name}`); + } catch (error) { + console.error("Failed to open URL:", error); + } + }; + + return ( + + {/* 添加仓库表单 */} +
+

+ {t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })} +

+
+
+ + setRepoUrl(e.target.value)} + className="mt-2" + /> +
+
+ + setBranch(e.target.value)} + className="mt-2" + /> +
+ {error && ( +

{error}

+ )} + +
+
+ + {/* 仓库列表 */} +
+

+ {t("templates.repo.list", { defaultValue: "仓库列表" })} +

+ {isLoading ? ( +
+ +
+ ) : repos.length === 0 ? ( +
+

+ {t("templates.repo.empty", { defaultValue: "暂无仓库" })} +

+
+ ) : ( +
+ {repos.map((repo) => ( +
+
+ + repo.id !== null && handleToggle(repo.id, checked) + } + disabled={toggleRepoMutation.isPending || repo.id === null} + /> +
+
+ {repo.owner}/{repo.name} +
+
+ + {t("templates.repo.branch", { defaultValue: "分支" })}:{" "} + {repo.branch} + + + {repo.enabled + ? t("templates.repo.enabled", { + defaultValue: "已启用", + }) + : t("templates.repo.disabled", { + defaultValue: "已禁用", + })} + +
+
+
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/templates/TemplatesPage.tsx b/src/components/templates/TemplatesPage.tsx new file mode 100644 index 000000000..b07f293d8 --- /dev/null +++ b/src/components/templates/TemplatesPage.tsx @@ -0,0 +1,334 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { RefreshCw, Search, Settings, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { ComponentCard } from "./ComponentCard"; +import { ComponentDetail } from "./ComponentDetail"; +import { CategoryFilter } from "./CategoryFilter"; +import { RepoManager } from "./RepoManager"; +import { BundleList } from "./BundleList"; +import { + useTemplateComponents, + useComponentCategories, + useInstallTemplateComponent, + useUninstallTemplateComponent, + useRefreshTemplateIndex, +} from "@/lib/query/template"; +import type { ComponentType, TemplateComponent } from "@/types/template"; +import type { AppType } from "@/lib/api/config"; + +interface TemplatesPageProps { + activeApp: AppType; +} + +export function TemplatesPage({ activeApp }: TemplatesPageProps) { + const { t } = useTranslation(); + const [selectedType, setSelectedType] = useState( + "bundle", + ); + const [selectedCategory, setSelectedCategory] = useState( + undefined, + ); + const [searchQuery, setSearchQuery] = useState(""); + const [repoManagerOpen, setRepoManagerOpen] = useState(false); + const [detailComponent, setDetailComponent] = useState< + TemplateComponent | undefined + >(undefined); + + // Queries + const { + data: componentsData, + isLoading: componentsLoading, + refetch: refetchComponents, + } = useTemplateComponents({ + componentType: selectedType === "bundle" ? undefined : selectedType, + category: selectedCategory, + search: searchQuery || undefined, + appType: activeApp, + }); + + const { data: categories = [] } = useComponentCategories( + selectedType === "bundle" ? undefined : selectedType, + ); + + // Mutations + const installMutation = useInstallTemplateComponent(); + const uninstallMutation = useUninstallTemplateComponent(); + const refreshMutation = useRefreshTemplateIndex(); + + const handleInstall = async (id: number, name: string) => { + try { + await installMutation.mutateAsync({ id, appType: activeApp }); + toast.success( + t("templates.installSuccess", { name, defaultValue: `已安装 ${name}` }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + t("templates.installFailed", { + name, + defaultValue: `安装 ${name} 失败`, + }), + { + description: errorMessage, + duration: 8000, + }, + ); + console.error("Install component failed:", error); + } + }; + + const handleUninstall = async (id: number, name: string) => { + try { + await uninstallMutation.mutateAsync({ id, appType: activeApp }); + toast.success( + t("templates.uninstallSuccess", { + name, + defaultValue: `已卸载 ${name}`, + }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + t("templates.uninstallFailed", { + name, + defaultValue: `卸载 ${name} 失败`, + }), + { + description: errorMessage, + duration: 8000, + }, + ); + console.error("Uninstall component failed:", error); + } + }; + + const handleRefresh = async () => { + try { + await refreshMutation.mutateAsync(); + await refetchComponents(); + toast.success( + t("templates.refreshSuccess", { defaultValue: "刷新成功" }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(t("templates.refreshFailed", { defaultValue: "刷新失败" }), { + description: errorMessage, + duration: 8000, + }); + console.error("Refresh index failed:", error); + } + }; + + const components = componentsData?.items || []; + + // 过滤组件 + const filteredComponents = useMemo(() => { + if (!searchQuery.trim()) return components; + + const query = searchQuery.toLowerCase(); + return components.filter((component) => { + const name = component.name?.toLowerCase() || ""; + const description = component.description?.toLowerCase() || ""; + const category = component.category?.toLowerCase() || ""; + + return ( + name.includes(query) || + description.includes(query) || + category.includes(query) + ); + }); + }, [components, searchQuery]); + + const componentTypeOptions: { + value: ComponentType | "bundle"; + icon: string; + }[] = [ + { value: "bundle", icon: "📦" }, + { value: "agent", icon: "🤖" }, + { value: "command", icon: "⚡" }, + { value: "mcp", icon: "🔌" }, + { value: "setting", icon: "⚙️" }, + { value: "hook", icon: "🪝" }, + { value: "skill", icon: "💡" }, + ]; + + return ( +
+ {/* 左侧分类过滤器 */} +
+ +
+ + {/* 右侧主内容区 */} +
+ {/* 顶部工具栏 */} +
+ {/* 操作按钮 */} +
+
+ + +
+
+ + {/* 搜索框 */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-3" + /> +
+
+ + {/* 类型标签页 */} + { + setSelectedType(value as ComponentType | "bundle"); + setSelectedCategory(undefined); + }} + className="flex-1 flex flex-col overflow-hidden" + > + + {componentTypeOptions.map((option) => ( + + {option.icon} + {t(`templates.type.${option.value}`, { + defaultValue: option.value, + })} + + ))} + + + {/* 组合标签页 */} + +
+ +
+
+ + {/* 组件类型标签页 */} + {componentTypeOptions + .filter((opt) => opt.value !== "bundle") + .map((option) => ( + +
+ {componentsLoading ? ( +
+ +
+ ) : filteredComponents.length === 0 ? ( +
+

+ {t("templates.empty", { defaultValue: "暂无组件" })} +

+

+ {t("templates.emptyDescription", { + defaultValue: "请添加模板仓库来获取组件", + })} +

+
+ ) : ( + <> + {searchQuery && ( +

+ {t("templates.count", { + count: filteredComponents.length, + defaultValue: `找到 ${filteredComponents.length} 个组件`, + })} +

+ )} +
+ {filteredComponents.map((component) => ( + { + if (component.id !== null) { + await handleInstall( + component.id, + component.name, + ); + } + }} + onUninstall={async () => { + if (component.id !== null) { + await handleUninstall( + component.id, + component.name, + ); + } + }} + onViewDetail={() => setDetailComponent(component)} + /> + ))} +
+ + )} +
+
+ ))} +
+
+ + {/* 仓库管理弹窗 */} + {repoManagerOpen && ( + setRepoManagerOpen(false)} /> + )} + + {/* 组件详情弹窗 */} + {detailComponent && detailComponent.id !== null && ( + setDetailComponent(undefined)} + onInstall={handleInstall} + onUninstall={handleUninstall} + /> + )} +
+ ); +} diff --git a/src/components/templates/index.ts b/src/components/templates/index.ts new file mode 100644 index 000000000..0ccabd9ae --- /dev/null +++ b/src/components/templates/index.ts @@ -0,0 +1,5 @@ +export { TemplatesPage } from "./TemplatesPage"; +export { ComponentCard } from "./ComponentCard"; +export { ComponentDetail } from "./ComponentDetail"; +export { CategoryFilter } from "./CategoryFilter"; +export { RepoManager } from "./RepoManager"; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 31c9f0da8..7a8b29a38 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -13,7 +13,7 @@ const buttonVariants = cva( "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700", // 危险按钮:红底白字(对应旧版 danger) destructive: - "bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700", + "bg-red-500 text-white hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600", // 轮廓按钮 outline: "border border-border-default bg-background hover:bg-gray-100 hover:border-border-hover dark:hover:bg-gray-800", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2226b977b..81c799c56 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -845,6 +845,89 @@ "agents": { "title": "Agents" }, + "templates": { + "title": "Template Market", + "search": "Search components...", + "refresh": "Refresh Index", + "refreshing": "Refreshing...", + "types": { + "agent": "Agent", + "command": "Command", + "mcp": "MCP Service", + "setting": "Setting", + "hook": "Hook", + "skill": "Skill" + }, + "categories": { + "all": "All", + "security": "Security", + "development": "Development", + "database": "Database", + "web-tools": "Web Tools" + }, + "card": { + "install": "Install", + "installed": "Installed", + "uninstall": "Uninstall", + "installing": "Installing..." + }, + "detail": { + "description": "Description", + "metadata": "Metadata", + "content": "Content Preview", + "viewOnGithub": "View on GitHub", + "installTo": "Install to", + "type": "Type", + "category": "Category", + "model": "Model", + "tools": "Tools" + }, + "repos": { + "title": "Template Repositories", + "add": "Add Repository", + "remove": "Remove", + "enable": "Enable", + "disable": "Disable", + "owner": "Owner", + "name": "Name", + "branch": "Branch" + }, + "errors": { + "refreshFailed": "Failed to refresh index", + "installFailed": "Installation failed", + "uninstallFailed": "Uninstallation failed", + "loadFailed": "Failed to load components" + }, + "empty": { + "noComponents": "No components", + "noResults": "No matching components found" + }, + "notifications": { + "installSuccess": "Component installed successfully", + "uninstallSuccess": "Component uninstalled successfully", + "refreshSuccess": "Index refreshed successfully" + }, + "type": { + "bundle": "Bundle", + "agent": "Agent", + "command": "Command", + "mcp": "MCP", + "setting": "Setting", + "hook": "Hook", + "skill": "Skill" + }, + "bundle": { + "empty": "No bundles", + "emptyDescription": "Add a template repository with components.json", + "installTo": "Install to:", + "componentCount": "{{count}} components", + "install": "Install Bundle", + "noMatch": "No matching components found", + "installSuccess": "Installed {{count}} components", + "partialFail": "{{count}} components failed to install", + "installFailed": "Failed to install bundle" + } + }, "proxy": { "failoverQueue": { "title": "Failover Queue", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 07f1a32ca..a62d43885 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -844,5 +844,88 @@ }, "agents": { "title": "エージェント" + }, + "templates": { + "title": "テンプレートマーケット", + "search": "コンポーネントを検索...", + "refresh": "インデックスを更新", + "refreshing": "更新中...", + "types": { + "agent": "エージェント", + "command": "コマンド", + "mcp": "MCP サービス", + "setting": "設定", + "hook": "フック", + "skill": "スキル" + }, + "categories": { + "all": "すべて", + "security": "セキュリティ", + "development": "開発", + "database": "データベース", + "web-tools": "ウェブツール" + }, + "card": { + "install": "インストール", + "installed": "インストール済み", + "uninstall": "アンインストール", + "installing": "インストール中..." + }, + "detail": { + "description": "説明", + "metadata": "メタデータ", + "content": "コンテンツプレビュー", + "viewOnGithub": "GitHub で見る", + "installTo": "インストール先", + "type": "タイプ", + "category": "カテゴリ", + "model": "モデル", + "tools": "ツール" + }, + "repos": { + "title": "テンプレートリポジトリ", + "add": "リポジトリを追加", + "remove": "削除", + "enable": "有効化", + "disable": "無効化", + "owner": "オーナー", + "name": "名前", + "branch": "ブランチ" + }, + "errors": { + "refreshFailed": "インデックスの更新に失敗しました", + "installFailed": "インストールに失敗しました", + "uninstallFailed": "アンインストールに失敗しました", + "loadFailed": "コンポーネントの読み込みに失敗しました" + }, + "empty": { + "noComponents": "コンポーネントなし", + "noResults": "一致するコンポーネントが見つかりません" + }, + "notifications": { + "installSuccess": "コンポーネントのインストールに成功しました", + "uninstallSuccess": "コンポーネントのアンインストールに成功しました", + "refreshSuccess": "インデックスの更新に成功しました" + }, + "type": { + "bundle": "バンドル", + "agent": "エージェント", + "command": "コマンド", + "mcp": "MCP", + "setting": "設定", + "hook": "フック", + "skill": "スキル" + }, + "bundle": { + "empty": "バンドルなし", + "emptyDescription": "components.json を含むテンプレートリポジトリを追加してください", + "installTo": "インストール先:", + "componentCount": "{{count}} 個のコンポーネント", + "install": "バンドルをインストール", + "noMatch": "一致するコンポーネントが見つかりません", + "installSuccess": "{{count}} 個のコンポーネントをインストールしました", + "partialFail": "{{count}} 個のコンポーネントのインストールに失敗しました", + "installFailed": "バンドルのインストールに失敗しました" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c63bbc74c..c76146c4a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -845,6 +845,89 @@ "agents": { "title": "智能体" }, + "templates": { + "title": "模板市场", + "search": "搜索组件...", + "refresh": "刷新索引", + "refreshing": "刷新中...", + "types": { + "agent": "代理", + "command": "命令", + "mcp": "MCP 服务", + "setting": "设置", + "hook": "钩子", + "skill": "技能" + }, + "categories": { + "all": "全部", + "security": "安全", + "development": "开发", + "database": "数据库", + "web-tools": "Web 工具" + }, + "card": { + "install": "安装", + "installed": "已安装", + "uninstall": "卸载", + "installing": "安装中..." + }, + "detail": { + "description": "描述", + "metadata": "元数据", + "content": "内容预览", + "viewOnGithub": "在 GitHub 上查看", + "installTo": "安装到", + "type": "类型", + "category": "分类", + "model": "模型", + "tools": "工具" + }, + "repos": { + "title": "模板仓库", + "add": "添加仓库", + "remove": "删除", + "enable": "启用", + "disable": "禁用", + "owner": "所有者", + "name": "名称", + "branch": "分支" + }, + "errors": { + "refreshFailed": "刷新索引失败", + "installFailed": "安装失败", + "uninstallFailed": "卸载失败", + "loadFailed": "加载组件失败" + }, + "empty": { + "noComponents": "暂无组件", + "noResults": "未找到匹配的组件" + }, + "notifications": { + "installSuccess": "组件安装成功", + "uninstallSuccess": "组件卸载成功", + "refreshSuccess": "索引刷新成功" + }, + "type": { + "bundle": "组合", + "agent": "代理", + "command": "命令", + "mcp": "MCP", + "setting": "设置", + "hook": "钩子", + "skill": "技能" + }, + "bundle": { + "empty": "暂无组合", + "emptyDescription": "请添加包含 components.json 的模板仓库", + "installTo": "安装到:", + "componentCount": "{{count}} 个组件", + "install": "安装组合", + "noMatch": "未找到匹配的组件", + "installSuccess": "已安装 {{count}} 个组件", + "partialFail": "{{count}} 个组件安装失败", + "installFailed": "安装组合失败" + } + }, "proxy": { "failoverQueue": { "title": "故障转移队列", diff --git a/src/lib/api/template.ts b/src/lib/api/template.ts new file mode 100644 index 000000000..ba68bd3ee --- /dev/null +++ b/src/lib/api/template.ts @@ -0,0 +1,102 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + TemplateRepo, + TemplateComponent, + ComponentDetail, + PaginatedResult, + ComponentFilter, + BatchInstallResult, + InstalledComponent, + ComponentType, +} from "@/types/template"; +import type { AppType } from "./config"; + +export const templateApi = { + // 模板仓库管理 + async listTemplateRepos(): Promise { + return invoke("list_template_repos"); + }, + + async addTemplateRepo( + owner: string, + name: string, + branch: string, + ): Promise { + return invoke("add_template_repo", { owner, name, branch }); + }, + + async removeTemplateRepo(id: number): Promise { + return invoke("remove_template_repo", { id }); + }, + + async toggleTemplateRepo(id: number, enabled: boolean): Promise { + return invoke("toggle_template_repo", { id, enabled }); + }, + + // 模板索引刷新 + async refreshTemplateIndex(): Promise { + return invoke("refresh_template_index"); + }, + + // 模板组件查询 + async listTemplateComponents( + filter: ComponentFilter, + ): Promise> { + return invoke("list_template_components", { + componentType: filter.componentType, + category: filter.category, + search: filter.search, + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 20, + appType: filter.appType, + }); + }, + + async getTemplateComponent(id: number): Promise { + return invoke("get_template_component", { id }); + }, + + async getComponentCategories( + componentType?: ComponentType, + ): Promise { + return invoke("list_template_categories", { componentType }); + }, + + // 组件安装管理 + async installTemplateComponent(id: number, appType: AppType): Promise { + return invoke("install_template_component", { id, appType }); + }, + + async uninstallTemplateComponent( + id: number, + appType: AppType, + ): Promise { + return invoke("uninstall_template_component", { id, appType }); + }, + + async batchInstallComponents( + ids: number[], + appType: AppType, + ): Promise { + return invoke("batch_install_template_components", { ids, appType }); + }, + + async listInstalledComponents( + appType?: AppType, + componentType?: ComponentType, + ): Promise { + return invoke("list_installed_components", { appType, componentType }); + }, + + // 组件内容预览 + async previewComponentContent(id: number): Promise { + return invoke("preview_component_content", { id }); + }, + + // 市场组合 + async listMarketplaceBundles(): Promise< + import("@/types/template").MarketplaceBundle[] + > { + return invoke("list_marketplace_bundles"); + }, +}; diff --git a/src/lib/query/template.ts b/src/lib/query/template.ts new file mode 100644 index 000000000..8c24fbed9 --- /dev/null +++ b/src/lib/query/template.ts @@ -0,0 +1,177 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { templateApi } from "@/lib/api/template"; +import type { ComponentFilter, ComponentType } from "@/types/template"; +import type { AppType } from "@/lib/api/config"; + +// Query keys +export const templateKeys = { + all: ["templates"] as const, + repos: () => [...templateKeys.all, "repos"] as const, + components: (filter: ComponentFilter) => + [...templateKeys.all, "components", filter] as const, + component: (id: number) => [...templateKeys.all, "component", id] as const, + categories: (type?: ComponentType) => + [...templateKeys.all, "categories", type] as const, + installed: (appType?: AppType, componentType?: ComponentType) => + [...templateKeys.all, "installed", appType, componentType] as const, + preview: (id: number) => [...templateKeys.all, "preview", id] as const, + bundles: () => [...templateKeys.all, "bundles"] as const, +}; + +// Hooks - 模板仓库 +export function useTemplateRepos() { + return useQuery({ + queryKey: templateKeys.repos(), + queryFn: templateApi.listTemplateRepos, + }); +} + +export function useAddTemplateRepo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { owner: string; name: string; branch: string }) => + templateApi.addTemplateRepo(params.owner, params.name, params.branch), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: templateKeys.repos() }); + }, + }); +} + +export function useRemoveTemplateRepo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => templateApi.removeTemplateRepo(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: templateKeys.repos() }); + queryClient.invalidateQueries({ queryKey: templateKeys.components({}) }); + }, + }); +} + +export function useToggleTemplateRepo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { id: number; enabled: boolean }) => + templateApi.toggleTemplateRepo(params.id, params.enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: templateKeys.repos() }); + }, + }); +} + +// Hooks - 模板索引 +export function useRefreshTemplateIndex() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: templateApi.refreshTemplateIndex, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: templateKeys.all }); + }, + }); +} + +// Hooks - 模板组件查询 +export function useTemplateComponents(filter: ComponentFilter) { + return useQuery({ + queryKey: templateKeys.components(filter), + queryFn: () => templateApi.listTemplateComponents(filter), + }); +} + +export function useTemplateComponent(id: number) { + return useQuery({ + queryKey: templateKeys.component(id), + queryFn: () => templateApi.getTemplateComponent(id), + enabled: id > 0, + }); +} + +export function useComponentCategories(componentType?: ComponentType) { + return useQuery({ + queryKey: templateKeys.categories(componentType), + queryFn: () => templateApi.getComponentCategories(componentType), + }); +} + +// Hooks - 组件安装 +export function useInstallTemplateComponent() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { id: number; appType: AppType }) => + templateApi.installTemplateComponent(params.id, params.appType), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: templateKeys.components({}), + }); + queryClient.invalidateQueries({ + queryKey: templateKeys.installed(), + }); + }, + }); +} + +export function useUninstallTemplateComponent() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { id: number; appType: AppType }) => + templateApi.uninstallTemplateComponent(params.id, params.appType), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: templateKeys.components({}), + }); + queryClient.invalidateQueries({ + queryKey: templateKeys.installed(), + }); + }, + }); +} + +export function useBatchInstallComponents() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { ids: number[]; appType: AppType }) => + templateApi.batchInstallComponents(params.ids, params.appType), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: templateKeys.components({}), + }); + queryClient.invalidateQueries({ + queryKey: templateKeys.installed(), + }); + }, + }); +} + +export function useInstalledComponents( + appType?: AppType, + componentType?: ComponentType, +) { + return useQuery({ + queryKey: templateKeys.installed(appType, componentType), + queryFn: () => templateApi.listInstalledComponents(appType, componentType), + }); +} + +// Hooks - 组件预览 +export function useComponentPreview(id: number) { + return useQuery({ + queryKey: templateKeys.preview(id), + queryFn: () => templateApi.previewComponentContent(id), + enabled: id > 0, + }); +} + +// Hooks - 市场组合 +export function useMarketplaceBundles() { + return useQuery({ + queryKey: templateKeys.bundles(), + queryFn: templateApi.listMarketplaceBundles, + }); +} diff --git a/src/types/template.ts b/src/types/template.ts new file mode 100644 index 000000000..d9792e3e1 --- /dev/null +++ b/src/types/template.ts @@ -0,0 +1,104 @@ +// Template 功能相关类型定义 + +// 组件类型 +export type ComponentType = + | "agent" + | "command" + | "mcp" + | "setting" + | "hook" + | "skill"; + +// 模板仓库 +export interface TemplateRepo { + id: number | null; + owner: string; + name: string; + branch: string; + enabled: boolean; + createdAt?: string; + updatedAt?: string; +} + +// 模板组件 +export interface TemplateComponent { + id: number | null; + repoId: number; + componentType: ComponentType; + category: string | null; + name: string; + path: string; + description: string | null; + contentHash: string | null; + installed: boolean; + createdAt?: string; + updatedAt?: string; +} + +// 组件详情(含完整内容) +export interface ComponentDetail extends TemplateComponent { + content: string; + repoOwner: string; + repoName: string; + repoBranch: string; + readmeUrl: string; +} + +// 已安装组件 +export interface InstalledComponent { + id: number | null; + componentId: number | null; + componentType: ComponentType; + name: string; + path: string; + appType: string; + installedAt: string; +} + +// 分页结果 +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +// 组件过滤选项 +export interface ComponentFilter { + componentType?: ComponentType; + category?: string; + search?: string; + page?: number; + pageSize?: number; + appType?: string; +} + +// 批量安装结果 +export interface BatchInstallResult { + success: number[]; + failed: Array<[number, string]>; +} + +// 组件元数据(从 YAML front matter 解析) +export interface ComponentMetadata { + name?: string; + description?: string; + tools?: string; // Agent 专用 + model?: string; // Agent 专用 +} + +// 市场组合项(plugin 中的单个组件) +export interface MarketplaceBundleItem { + name: string; + path: string; + componentType: ComponentType; +} + +// 市场组合(预设组件集合) +export interface MarketplaceBundle { + id: string; + name: string; + description: string; + category: string; + components: MarketplaceBundleItem[]; +}