Compare commits

..

3 Commits

Author SHA1 Message Date
YoVinchen 2eb1670e93 style: fix code formatting and defer templates migration 2026-01-14 11:42:47 +08:00
YoVinchen 929ac54cc9 Merge branch 'main' into feature/claude-code-templates
# Conflicts:
#	src-tauri/src/database/dao/mod.rs
#	src-tauri/src/database/schema.rs
#	src-tauri/src/lib.rs
#	src/App.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
2026-01-14 00:45:20 +08:00
YoVinchen 4bbd63bf2b feat(templates): add Claude Code Templates marketplace
- Add template repository management and component discovery
- Implement template installation for agents, commands, hooks, MCPs, skills, settings
- Support multi-app installation (Claude/Codex/Gemini)
- Add frontend components for browsing and installing templates
- Include i18n translations for zh/en/ja
2025-12-19 09:42:20 +08:00
46 changed files with 6481 additions and 330 deletions
+1
View File
@@ -727,6 +727,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
+1
View File
@@ -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"
+2
View File
@@ -15,6 +15,7 @@ mod proxy;
mod settings;
pub mod skill;
mod stream_check;
mod template;
mod usage;
pub use config::*;
@@ -32,4 +33,5 @@ pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use template::*;
pub use usage::*;
-2
View File
@@ -133,7 +133,6 @@ pub async fn testUsageScript(
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
@@ -146,7 +145,6 @@ pub async fn testUsageScript(
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
+278
View File
@@ -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<String>,
category: Option<String>,
search: Option<String>,
page: u32,
page_size: u32,
app_type: Option<String>,
) -> Result<PaginatedResult<TemplateComponent>, 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<ComponentDetail, String> {
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<i64>,
app_type: String,
) -> Result<BatchInstallResult, String> {
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<Vec<TemplateRepo>, AppError> {
state.db.list_repos()
}
/// 添加模板仓库
#[tauri::command]
pub fn add_template_repo(
state: State<'_, AppState>,
owner: String,
name: String,
branch: String,
) -> Result<i64, AppError> {
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<String>,
) -> Result<Vec<String>, 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::<Result<Vec<String>, _>>()?;
Ok(categories)
}
/// 获取已安装组件列表
#[tauri::command]
pub fn list_installed_components(
state: State<'_, AppState>,
app_type: Option<String>,
component_type: Option<String>,
) -> Result<Vec<InstalledComponent>, 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<String, 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
.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<Vec<crate::services::MarketplaceBundle>, 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}"))?
}
+1
View File
@@ -10,6 +10,7 @@ pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod template;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
+595
View File
@@ -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<i64, AppError> {
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<Option<TemplateRepo>, 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<Vec<TemplateRepo>, 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<i64, AppError> {
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<Option<TemplateComponent>, 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<TemplateComponent>, u32), AppError> {
let conn = lock_conn!(self.conn);
// 构建 WHERE 子句
let mut where_clauses = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = 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(&params_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(&params_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<i64, AppError> {
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<i64, AppError> {
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<Vec<InstalledComponent>, AppError> {
let conn = lock_conn!(self.conn);
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::ToSql>>) = 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(&params_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<Vec<InstalledComponent>, AppError> {
let conn = lock_conn!(self.conn);
// 构建 WHERE 子句
let mut where_clauses = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = 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(&params_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<bool, AppError> {
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<Vec<i64>, 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<i64> = 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)
}
}
+181
View File
@@ -302,6 +302,90 @@ impl Database {
[],
);
// 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(())
}
@@ -346,6 +430,12 @@ impl Database {
Self::migrate_v2_to_v3(conn)?;
Self::set_user_version(conn, 3)?;
}
// v3 -> v4: Claude Code Templates 市场功能(暂未启用)
// 3 => {
// log::info!("迁移数据库从 v3 到 v4Claude Code Templates 市场功能)");
// Self::migrate_v3_to_v4(conn)?;
// Self::set_user_version(conn, 4)?;
// }
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -786,6 +876,97 @@ impl Database {
Ok(())
}
/// v3 -> v4 迁移:添加 Claude Code Templates 功能相关表
#[allow(dead_code)]
fn migrate_v3_to_v4(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(())
}
/// v2 -> v3 迁移:Skills 统一管理架构
///
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
+88 -8
View File
@@ -518,8 +518,7 @@ fn model_pricing_is_seeded_on_init() {
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
"模型定价数据应该在初始化时自动填充,实际数量: {count}"
);
// 验证包含 Claude 模型
@@ -532,8 +531,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
"应该包含 Claude 模型定价,实际数量: {claude_count}"
);
// 验证包含 GPT 模型
@@ -546,8 +544,7 @@ fn model_pricing_is_seeded_on_init() {
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
"应该包含 GPT 模型定价,实际数量: {gpt_count}"
);
// 验证包含 Gemini 模型
@@ -560,7 +557,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 个外键约束");
}
-1
View File
@@ -225,7 +225,6 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
};
+2 -4
View File
@@ -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();
@@ -383,8 +382,7 @@ 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();
+16
View File
@@ -880,6 +880,22 @@ pub fn run() {
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// 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
-4
View File
@@ -98,10 +98,6 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
/// 模板类型(用于后端判断验证规则)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
+7
View File
@@ -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;
@@ -19,6 +20,12 @@ pub use proxy::ProxyService;
pub use skill::{DiscoverableSkill, 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,
-2
View File
@@ -615,7 +615,6 @@ impl ProviderService {
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
@@ -627,7 +626,6 @@ impl ProviderService {
base_url,
access_token,
user_id,
template_type,
)
.await
}
+1 -7
View File
@@ -17,7 +17,6 @@ pub(crate) async fn execute_and_format_usage_result(
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
@@ -26,7 +25,6 @@ pub(crate) async fn execute_and_format_usage_result(
timeout,
access_token,
user_id,
template_type,
)
.await
{
@@ -115,7 +113,7 @@ pub async fn query_usage(
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
@@ -166,7 +164,6 @@ pub async fn query_usage(
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
usage_script.template_type.clone(),
)
};
@@ -177,7 +174,6 @@ pub async fn query_usage(
timeout,
access_token.as_deref(),
user_id.as_deref(),
template_type.as_deref(),
)
.await
}
@@ -194,7 +190,6 @@ pub async fn test_usage_script(
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
execute_and_format_usage_result(
@@ -204,7 +199,6 @@ pub async fn test_usage_script(
timeout,
access_token,
user_id,
template_type,
)
.await
}
@@ -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<Value> {
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<PathBuf> {
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<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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, &current)?;
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()
}
}
@@ -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<PathBuf> {
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<toml::Table> {
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<toml::Table> {
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<toml::Value> = 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<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
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, &current)?;
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, &current)?;
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()
}
}
@@ -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<Value> {
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<PathBuf> {
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<Value> {
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<PathBuf> {
self.install_markdown_file(content, "agents", name)
}
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf> {
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, &current)?;
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, &current)?;
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()
}
}
@@ -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<PathBuf>;
/// 安装 Command 到应用配置目录
///
/// # 参数
/// - `content`: Command 内容(Markdown 格式)
/// - `name`: Command 名称(用作文件名)
///
/// # 返回
/// 安装后的文件路径
fn install_command(&self, content: &str, name: &str) -> Result<PathBuf>;
/// 安装 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<dyn AppAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
}
}
+746
View File
@@ -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<Result<Vec<TemplateComponent>>> =
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<Vec<TemplateComponent>> {
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<PathBuf> {
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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent>,
) -> 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<TemplateComponent> {
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<TemplateComponent> {
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<TemplateComponent> {
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<ComponentMetadata> {
// 移除 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<String> {
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<i64> = 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<ComponentType>,
category: Option<String>,
search: Option<String>,
page: u32,
page_size: u32,
) -> Result<super::PaginatedResult<TemplateComponent>> {
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = 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::<Result<Vec<_>, _>>()?;
Ok(super::PaginatedResult {
items: components,
total,
page,
page_size,
})
}
}
@@ -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<ComponentDetail> {
// 查询组件基本信息
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<String> {
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<serde_json::Value> = 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<i64>,
app_type: &str,
) -> Result<BatchInstallResult> {
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<Vec<InstalledComponent>> {
let (sql, params): (String, Vec<Box<dyn rusqlite::ToSql>>) = 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::<Result<Vec<_>, _>>()?;
Ok(components)
}
/// 预览组件内容(仅获取内容,不进行安装)
pub async fn preview_content(&self, conn: &Connection, id: i64) -> Result<String> {
// 查询组件基本信息
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)
}
}
+357
View File
@@ -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<Self> {
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<i64>,
pub owner: String,
pub name: String,
pub branch: String,
pub enabled: bool,
#[serde(rename = "createdAt")]
pub created_at: Option<DateTime<Utc>>,
#[serde(rename = "updatedAt")]
pub updated_at: Option<DateTime<Utc>>,
}
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<i64>,
#[serde(rename = "repoId")]
pub repo_id: i64,
#[serde(rename = "componentType")]
pub component_type: ComponentType,
pub category: Option<String>,
pub name: String,
pub path: String,
pub description: Option<String>,
#[serde(rename = "contentHash")]
pub content_hash: Option<String>,
/// 是否已安装(前端展示用,需要在查询时填充)
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<String>,
pub description: Option<String>,
/// Agent 专用 - 工具列表
pub tools: Option<String>,
/// Agent 专用 - 模型名称
pub model: Option<String>,
}
/// 分页结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResult<T> {
pub items: Vec<T>,
pub total: i64,
pub page: u32,
#[serde(rename = "pageSize")]
pub page_size: u32,
}
/// 批量安装结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchInstallResult {
pub success: Vec<i64>,
pub failed: Vec<(i64, String)>,
}
/// 已安装组件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledComponent {
pub id: Option<i64>,
#[serde(rename = "componentId")]
pub component_id: Option<i64>,
#[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<Utc>,
}
/// 市场组合项(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<MarketplaceBundleItem>,
}
/// Template 服务
pub struct TemplateService {
http_client: Client,
}
impl TemplateService {
pub fn new() -> Result<Self> {
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<PathBuf> {
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<Vec<MarketplaceBundle>> {
// 获取启用的仓库
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::<serde_json::Value>().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 失败")
}
}
+239
View File
@@ -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<Vec<TemplateRepo>> {
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::<Result<Vec<_>, _>>()
.context("收集模板仓库结果失败")?;
Ok(repos)
}
/// 添加模板仓库
pub fn add_repo(&self, conn: &Connection, repo: TemplateRepo) -> Result<i64> {
// 检查是否已存在
let existing: Option<i64> = 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<bool> {
// 获取当前状态
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<TemplateRepo> {
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<Vec<TemplateRepo>> {
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::<Result<Vec<_>, _>>()
.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);
}
}
+63 -95
View File
@@ -13,21 +13,13 @@ pub async fn execute_usage_script(
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<Value, AppError> {
// 检测是否为自定义模板模式
// 优先使用前端传递的 template_type
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
// 1. 替换模板变量,避免泄露敏感信息
let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
if !base_url.is_empty() {
validate_base_url(base_url)?;
}
// 2. 验证 base_url 的安全性
validate_base_url(base_url)?;
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
@@ -105,8 +97,7 @@ pub async fn execute_usage_script(
})?;
// 5. 验证请求 URL 是否安全(防止 SSRF)
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
validate_request_url(&request.url, base_url, is_custom_template)?;
validate_request_url(&request.url, base_url)?;
// 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
@@ -481,11 +472,7 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
}
/// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url(
request_url: &str,
base_url: &str,
is_custom_template: bool,
) -> Result<(), AppError> {
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
@@ -495,11 +482,19 @@ fn validate_request_url(
)
})?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发)
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
if parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
@@ -507,85 +502,60 @@ fn validate_request_url(
));
}
// 如果提供了 base_url(非空),则进行同源检查
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
if !base_url.is_empty() && !is_custom_template {
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
@@ -873,7 +843,7 @@ mod tests {
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url, false);
let result = validate_request_url(request_url, base_url);
if should_match {
assert!(
@@ -886,9 +856,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}"
);
}
}
+30 -16
View File
@@ -13,6 +13,7 @@ import {
Wrench,
Server,
RefreshCw,
Package,
Search,
Download,
} from "lucide-react";
@@ -48,6 +49,7 @@ import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { TemplatesPage } from "@/components/templates/TemplatesPage";
import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button";
@@ -58,6 +60,7 @@ type View =
| "skills"
| "skillsDiscovery"
| "mcp"
| "templates"
| "agents"
| "universal";
@@ -472,6 +475,8 @@ function App() {
<UniversalProviderPanel />
</div>
);
case "templates":
return <TemplatesPage activeApp={activeApp} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -612,6 +617,7 @@ function App() {
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "templates" && t("templates.title")}
{currentView === "universal" &&
t("universalProvider.title", {
defaultValue: "统一供应商",
@@ -619,8 +625,8 @@ function App() {
</h1>
</div>
) : (
<div className="flex items-center gap-2">
<div className="relative inline-flex items-center">
<>
<div className="flex items-center gap-2">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
@@ -634,27 +640,26 @@ function App() {
>
CC Switch
</a>
<UpdateBadge
<Button
variant="ghost"
size="icon"
onClick={() => {
setSettingsDefaultTab("about");
setSettingsDefaultTab("general");
setCurrentView("settings");
}}
className="absolute -top-4 -right-4"
/>
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("general");
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
</div>
/>
</>
)}
</div>
@@ -791,6 +796,15 @@ function App() {
>
<Server className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("templates")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("templates.title")}
>
<Package className="h-4 w-4" />
</Button>
</div>
<Button
+42 -25
View File
@@ -1,6 +1,6 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "@/contexts/UpdateContext";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface UpdateBadgeProps {
className?: string;
@@ -8,39 +8,56 @@ interface UpdateBadgeProps {
}
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo } = useUpdate();
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
const { t } = useTranslation();
const isActive = hasUpdate && updateInfo;
const title = isActive
? t("settings.updateAvailable", {
version: updateInfo?.availableVersion ?? "",
})
: t("settings.checkForUpdates");
if (!isActive) {
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
return null;
}
return (
<Button
type="button"
variant="ghost"
size="icon"
title={title}
aria-label={title}
onClick={onClick}
<div
className={`
relative h-6 w-6 rounded-full
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
flex items-center gap-1.5 px-2.5 py-1
bg-white dark:bg-gray-800
border border-border-default
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
${className}
`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<span
className={`
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
`}
/>
</Button>
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
<span className="text-gray-700 dark:text-gray-300 font-medium">
{t("settings.updateBadge")}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
}
+12 -154
View File
@@ -2,10 +2,8 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -111,67 +109,19 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onSave,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
const getProviderCredentials = (): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude") {
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
return {
apiKey: auth.OPENAI_API_KEY,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
}
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
};
const providerCredentials = getProviderCredentials();
const [script, setScript] = useState<UsageScript>(() => {
const savedScript = provider.meta?.usage_script;
const defaultScript = {
enabled: false,
language: "javascript" as const,
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
};
if (!savedScript) {
return defaultScript;
}
return savedScript;
return (
provider.meta?.usage_script || {
enabled: false,
language: "javascript",
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
}
);
});
const [testing, setTesting] = useState(false);
@@ -226,11 +176,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// 优先使用保存的 templateType
if (existingScript?.templateType) {
return existingScript.templateType;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API;
@@ -256,16 +201,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
// 保存时记录当前选择的模板类型
const scriptWithTemplate = {
...script,
templateType: selectedTemplate as
| "custom"
| "general"
| "newapi"
| undefined,
};
onSave(scriptWithTemplate);
onSave(script);
onClose();
};
@@ -281,7 +217,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.baseUrl,
script.accessToken,
script.userId,
selectedTemplate as "custom" | "general" | "newapi" | undefined,
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
@@ -294,9 +229,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
duration: 3000,
closeButton: true,
});
// 🔧 测试成功后,更新主界面列表的用量查询缓存
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
@@ -346,13 +278,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
// 这样可以避免同源检查导致的问题
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
setScript({
...script,
code: preset,
// 清除凭证,用户可选择手动输入或保持空
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
@@ -473,74 +401,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
})}
</div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
</h4>
<div className="space-y-1 text-xs">
{/* baseUrl */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
{/* apiKey */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
</code>
)}
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={12} />
) : (
<Eye size={12} />
)}
</button>
</>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
</div>
</div>
)}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
@@ -741,13 +601,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
type="number"
min={0}
max={1440}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
}
value={script.autoIntervalMinutes ?? 0}
onChange={(e) =>
setScript({
...script,
autoQueryInterval: validateAndClampInterval(
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
@@ -755,7 +613,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onBlur={(e) =>
setScript({
...script,
autoQueryInterval: validateAndClampInterval(
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
+163
View File
@@ -0,0 +1,163 @@
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 { Download, Loader2, Trash2 } from "lucide-react";
import type { MarketplaceBundle } from "@/types/template";
import type { AppType } from "@/lib/api/config";
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
agent: "🤖",
command: "⚡",
mcp: "🔌",
setting: "⚙️",
hook: "🪝",
skill: "💡",
};
interface BundleInstallStatus {
installed: boolean;
installedIds: number[];
totalCount: number;
installedCount: number;
}
interface BundleDetailProps {
bundle: MarketplaceBundle;
status?: BundleInstallStatus;
selectedApp: AppType;
onClose: () => void;
onInstall: () => void;
onUninstall: () => void;
installing: boolean;
uninstalling: boolean;
}
export function BundleDetail({
bundle,
status,
onClose,
onInstall,
onUninstall,
installing,
uninstalling,
}: BundleDetailProps) {
const { t } = useTranslation();
// 按类型分组组件
const componentsByType = bundle.components.reduce(
(acc, comp) => {
const type = comp.componentType;
if (!acc[type]) acc[type] = [];
acc[type].push(comp);
return acc;
},
{} as Record<string, typeof bundle.components>,
);
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-3xl">📦</span>
{status?.installed && (
<Badge
variant="default"
className="bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
<DialogTitle className="text-2xl">{bundle.name}</DialogTitle>
<DialogDescription className="text-sm mt-2">
{bundle.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* 组件列表 */}
<div className="flex-1 overflow-y-auto space-y-6 py-4 px-1">
{Object.entries(componentsByType).map(([type, components]) => (
<div key={type}>
<div className="flex items-center justify-center gap-2 mb-3">
<span className="text-xl">
{componentTypeIcons[type] || "📦"}
</span>
<h3 className="font-medium text-foreground">
{t(`templates.type.${type}`, { defaultValue: type })}
</h3>
<Badge variant="secondary" className="text-xs">
{components.length}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 px-2">
{components.map((comp, idx) => (
<div
key={`${comp.name}-${idx}`}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30"
>
<span className="text-lg">
{componentTypeIcons[type] || "📦"}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{comp.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{comp.path}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t("common.close", { defaultValue: "关闭" })}
</Button>
{status && status.installedCount > 0 && (
<Button
variant="destructive"
onClick={onUninstall}
disabled={uninstalling}
>
{uninstalling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
</Button>
)}
{!status?.installed && (
<Button onClick={onInstall} disabled={installing}>
{installing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{t("templates.bundle.install", { defaultValue: "安装组合" })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+399
View File
@@ -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<string, string> = {
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<string, number> = {};
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<string | null>(null);
const [uninstallingBundle, setUninstallingBundle] = useState<string | null>(
null,
);
const [bundleStatuses, setBundleStatuses] = useState<
Record<string, BundleInstallStatus>
>({});
const [detailBundle, setDetailBundle] = useState<MarketplaceBundle | null>(
null,
);
const { data: bundles = [], isLoading } = useMarketplaceBundles();
const batchInstallMutation = useBatchInstallComponents();
// 检查组合安装状态
const checkBundleStatus = useCallback(
async (bundle: MarketplaceBundle): Promise<BundleInstallStatus> => {
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<string, string[]>,
);
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<string, BundleInstallStatus> = {};
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<string, string[]>,
);
// 收集所有匹配的组件 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 (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (bundles.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-foreground">
{t("templates.bundle.empty", { defaultValue: "暂无组合" })}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("templates.bundle.emptyDescription", {
defaultValue: "请添加包含 components.json 的模板仓库",
})}
</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{bundles.map((bundle) => {
const typeCounts = getComponentTypeCounts(bundle.components);
const status = bundleStatuses[bundle.id];
return (
<div
key={bundle.id}
className="glass-card rounded-xl p-4 flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer"
onClick={() => setDetailBundle(bundle)}
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
{/* 头部 */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-2xl">📦</span>
</div>
<h3 className="font-semibold text-foreground truncate">
{bundle.name}
</h3>
</div>
{status?.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
{/* 描述 */}
<p className="text-sm text-muted-foreground/90 line-clamp-2 leading-relaxed mb-3 flex-1">
{bundle.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</p>
{/* 组件类型统计 */}
<div className="flex flex-wrap gap-1.5 mb-3">
{Object.entries(typeCounts).map(([type, count]) => (
<Badge key={type} variant="secondary" className="text-xs">
<span className="mr-1">
{componentTypeIcons[type] || "📦"}
</span>
{type} {count}
</Badge>
))}
</div>
{/* 底部操作栏 */}
<div className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDetailBundle(bundle);
}}
className="flex-1"
>
<FileText className="h-3.5 w-3.5 mr-1.5" />
{t("templates.viewDetail", { defaultValue: "查看详情" })}
</Button>
{status && status.installedCount > 0 && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleUninstallBundle(bundle);
}}
disabled={uninstallingBundle === bundle.id}
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
>
{uninstallingBundle === bundle.id ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{t("templates.bundle.uninstall", { defaultValue: "卸载" })}
</Button>
)}
{!status?.installed && (
<Button
variant="mcp"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInstallBundle(bundle);
}}
disabled={installingBundle === bundle.id}
className="flex-1"
>
{installingBundle === bundle.id ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
{t("templates.bundle.install", { defaultValue: "安装" })}
</Button>
)}
</div>
</div>
);
})}
</div>
{/* 详情弹窗 */}
{detailBundle && (
<BundleDetail
bundle={detailBundle}
status={bundleStatuses[detailBundle.id]}
selectedApp={selectedApp}
onClose={() => setDetailBundle(null)}
onInstall={() => handleInstallBundle(detailBundle)}
onUninstall={() => handleUninstallBundle(detailBundle)}
installing={installingBundle === detailBundle.id}
uninstalling={uninstallingBundle === detailBundle.id}
/>
)}
</>
);
}
@@ -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 (
<div className="glass-card rounded-xl p-4 sticky top-0">
<h3 className="text-sm font-semibold text-foreground mb-3">
{t("templates.category.title", { defaultValue: "分类" })}
</h3>
<div className="h-[calc(100vh-16rem)] overflow-y-auto">
<div className="space-y-1 pr-2">
{/* 全部选项 */}
<Button
variant={selectedCategory === undefined ? "secondary" : "ghost"}
size="sm"
onClick={() => onSelectCategory(undefined)}
className="w-full justify-start text-sm h-9"
>
{t("templates.category.all", { defaultValue: "全部" })}
{selectedCategory === undefined && (
<Badge variant="secondary" className="ml-auto text-xs">
</Badge>
)}
</Button>
{/* 分类列表 */}
{categories.length > 0 && (
<>
<div className="h-px bg-border my-2" />
{categories.map((category) => (
<Button
key={category}
variant={
selectedCategory === category ? "secondary" : "ghost"
}
size="sm"
onClick={() => onSelectCategory(category)}
className="w-full justify-start text-sm h-9"
>
<span className="truncate">{category}</span>
{selectedCategory === category && (
<Badge variant="secondary" className="ml-auto text-xs">
</Badge>
)}
</Button>
))}
</>
)}
{/* 无分类提示 */}
{categories.length === 0 && selectedCategory === undefined && (
<p className="text-xs text-muted-foreground text-center py-4">
{t("templates.category.empty", { defaultValue: "暂无分类" })}
</p>
)}
</div>
</div>
</div>
);
}
+165
View File
@@ -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<void>;
onUninstall: () => Promise<void>;
onViewDetail: () => void;
}
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
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 (
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div onClick={onViewDetail}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-2xl">{typeIcon}</span>
</div>
<CardTitle className="text-base font-semibold truncate">
{component.name}
</CardTitle>
{component.category && (
<CardDescription className="text-xs mt-1">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 border-border-default"
>
{component.category}
</Badge>
</CardDescription>
)}
</div>
{component.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<p className="text-sm text-muted-foreground/90 line-clamp-3 leading-relaxed">
{component.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</p>
</CardContent>
</div>
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onViewDetail();
}}
disabled={loading}
className="flex-1"
>
<FileText className="h-3.5 w-3.5 mr-1.5" />
{t("templates.viewDetail", { defaultValue: "查看详情" })}
</Button>
{component.installed ? (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleUninstall();
}}
disabled={loading}
className="flex-1 border-red-300 text-red-500 hover:bg-red-50 hover:text-red-600 dark:border-red-500/50 dark:text-red-400 dark:hover:bg-red-900/30 dark:hover:text-red-300"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{loading
? t("templates.uninstalling", { defaultValue: "卸载中..." })
: t("templates.uninstall", { defaultValue: "卸载" })}
</Button>
) : (
<Button
variant="mcp"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInstall();
}}
disabled={loading}
className="flex-1"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
{loading
? t("templates.installing", { defaultValue: "安装中..." })
: t("templates.install", { defaultValue: "安装" })}
</Button>
)}
</CardFooter>
</Card>
);
}
@@ -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<void>;
onUninstall: (id: number, name: string) => Promise<void>;
}
// 组件类型图标映射
const componentTypeIcons: Record<string, string> = {
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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</DialogContent>
</Dialog>
);
}
const typeIcon = componentTypeIcons[component.componentType] || "📦";
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-3xl">{typeIcon}</span>
<Badge variant="outline" className="text-xs">
{t(`templates.type.${component.componentType}`, {
defaultValue: component.componentType,
})}
</Badge>
{component.category && (
<Badge variant="secondary" className="text-xs">
{component.category}
</Badge>
)}
</div>
<DialogTitle className="text-2xl">{component.name}</DialogTitle>
<DialogDescription className="text-sm mt-2">
{component.description ||
t("templates.noDescription", { defaultValue: "暂无描述" })}
</DialogDescription>
</div>
{component.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 text-white border-0"
>
{t("templates.installed", { defaultValue: "已安装" })}
</Badge>
)}
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="info" className="h-full flex flex-col">
<TabsList>
<TabsTrigger value="info">
{t("templates.detail.info", { defaultValue: "信息" })}
</TabsTrigger>
<TabsTrigger value="preview">
<FileCode className="h-3.5 w-3.5 mr-1.5" />
{t("templates.detail.preview", { defaultValue: "预览" })}
</TabsTrigger>
</TabsList>
<TabsContent value="info" className="flex-1 overflow-y-auto mt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* 类型 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Tag className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.type", { defaultValue: "类型" })}
</p>
<p className="text-sm font-medium truncate">
{t(`templates.type.${component.componentType}`, {
defaultValue: component.componentType,
})}
</p>
</div>
</div>
{/* 分类 */}
{component.category && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Tag className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.category", {
defaultValue: "分类",
})}
</p>
<p className="text-sm font-medium truncate">
{component.category}
</p>
</div>
</div>
)}
{/* 仓库 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<FolderGit2 className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.repository", {
defaultValue: "仓库",
})}
</p>
<div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">
{component.repoOwner}/{component.repoName}
{component.repoBranch &&
component.repoBranch !== "main" &&
` (${component.repoBranch})`}
</p>
{component.readmeUrl && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenGithub}
className="h-6 w-6 p-0"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
{/* 更新时间 */}
{component.updatedAt && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Clock className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{t("templates.detail.updatedAt", {
defaultValue: "更新时间",
})}
</p>
<p className="text-sm font-medium truncate">
{new Date(component.updatedAt).toLocaleString()}
</p>
</div>
</div>
)}
</div>
{/* 路径 */}
<div className="mt-3 p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1">
{t("templates.detail.path", { defaultValue: "路径" })}
</p>
<p className="text-sm font-mono text-muted-foreground break-all">
{component.path}
</p>
</div>
</TabsContent>
<TabsContent
value="preview"
className="flex-1 overflow-y-auto mt-4"
>
{previewLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : preview ? (
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-auto font-mono whitespace-pre-wrap break-words">
{preview}
</pre>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
{t("templates.detail.noPreview", {
defaultValue: "无法预览内容",
})}
</p>
)}
</TabsContent>
</Tabs>
</div>
<DialogFooter className="flex-row gap-2 justify-end border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t("common.close", { defaultValue: "关闭" })}
</Button>
{component.installed ? (
<Button
variant="destructive"
onClick={handleUninstall}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{loading
? t("templates.uninstalling", { defaultValue: "卸载中..." })
: t("templates.uninstall", { defaultValue: "卸载" })}
</Button>
) : (
<Button onClick={handleInstall} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{loading
? t("templates.installing", { defaultValue: "安装中..." })
: t("templates.install", { defaultValue: "安装" })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+294
View File
@@ -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 (
<FullScreenPanel
isOpen={true}
title={t("templates.repo.title", { defaultValue: "模板仓库管理" })}
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
{t("templates.repo.addTitle", { defaultValue: "添加模板仓库" })}
</h3>
<div className="space-y-4">
<div>
<Label htmlFor="repo-url" className="text-foreground">
{t("templates.repo.url", { defaultValue: "仓库地址" })}
</Label>
<Input
id="repo-url"
placeholder={t("templates.repo.urlPlaceholder", {
defaultValue: "owner/repo 或 https://github.com/owner/repo",
})}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="branch" className="text-foreground">
{t("templates.repo.branch", { defaultValue: "分支" })}
</Label>
<Input
id="branch"
placeholder={t("templates.repo.branchPlaceholder", {
defaultValue: "main",
})}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button
onClick={handleAdd}
disabled={addRepoMutation.isPending}
className="bg-primary text-primary-foreground hover:bg-primary/90"
type="button"
>
{addRepoMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{t("templates.repo.add", { defaultValue: "添加仓库" })}
</Button>
</div>
</div>
{/* 仓库列表 */}
<div className="space-y-4">
<h3 className="text-base font-semibold text-foreground">
{t("templates.repo.list", { defaultValue: "仓库列表" })}
</h3>
{isLoading ? (
<div className="flex items-center justify-center py-12 glass-card rounded-xl">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : repos.length === 0 ? (
<div className="text-center py-12 glass-card rounded-xl">
<p className="text-sm text-muted-foreground">
{t("templates.repo.empty", { defaultValue: "暂无仓库" })}
</p>
</div>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div
key={repo.id ?? `${repo.owner}-${repo.name}`}
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
>
<div className="flex items-center gap-4 flex-1 min-w-0">
<Switch
checked={repo.enabled}
onCheckedChange={(checked) =>
repo.id !== null && handleToggle(repo.id, checked)
}
disabled={toggleRepoMutation.isPending || repo.id === null}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{t("templates.repo.branch", { defaultValue: "分支" })}:{" "}
{repo.branch}
</span>
<Badge
variant={repo.enabled ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0 h-4"
>
{repo.enabled
? t("templates.repo.enabled", {
defaultValue: "已启用",
})
: t("templates.repo.disabled", {
defaultValue: "已禁用",
})}
</Badge>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleOpenRepo(repo.owner, repo.name)}
title={t("common.view", { defaultValue: "查看" })}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() =>
repo.id !== null &&
handleRemove(repo.id, repo.owner, repo.name)
}
disabled={removeRepoMutation.isPending || repo.id === null}
title={t("common.delete", { defaultValue: "删除" })}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</FullScreenPanel>
);
}
+334
View File
@@ -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<ComponentType | "bundle">(
"bundle",
);
const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
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 (
<div className="mx-auto max-w-[80rem] px-6 flex h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 左侧分类过滤器 */}
<div className="w-48 shrink-0 mr-6 overflow-y-auto">
<CategoryFilter
categories={categories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
</div>
{/* 右侧主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部工具栏 */}
<div className="mb-6 space-y-4">
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-4">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshMutation.isPending}
>
{refreshMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{t("templates.refresh", { defaultValue: "刷新索引" })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRepoManagerOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{t("templates.manageRepos", { defaultValue: "管理仓库" })}
</Button>
</div>
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("templates.searchPlaceholder", {
defaultValue: "搜索组件...",
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-3"
/>
</div>
</div>
{/* 类型标签页 */}
<Tabs
value={selectedType}
onValueChange={(value) => {
setSelectedType(value as ComponentType | "bundle");
setSelectedCategory(undefined);
}}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start mb-4">
{componentTypeOptions.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
<span className="mr-1.5">{option.icon}</span>
{t(`templates.type.${option.value}`, {
defaultValue: option.value,
})}
</TabsTrigger>
))}
</TabsList>
{/* 组合标签页 */}
<TabsContent value="bundle" className="flex-1 overflow-y-auto mt-0">
<div className="py-4">
<BundleList selectedApp={activeApp} />
</div>
</TabsContent>
{/* 组件类型标签页 */}
{componentTypeOptions
.filter((opt) => opt.value !== "bundle")
.map((option) => (
<TabsContent
key={option.value}
value={option.value}
className="flex-1 overflow-y-auto mt-0"
>
<div className="py-4">
{componentsLoading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredComponents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("templates.empty", { defaultValue: "暂无组件" })}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("templates.emptyDescription", {
defaultValue: "请添加模板仓库来获取组件",
})}
</p>
</div>
) : (
<>
{searchQuery && (
<p className="mb-4 text-sm text-muted-foreground">
{t("templates.count", {
count: filteredComponents.length,
defaultValue: `找到 ${filteredComponents.length} 个组件`,
})}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredComponents.map((component) => (
<ComponentCard
key={
component.id ??
`${component.repoId}-${component.path}`
}
component={component}
onInstall={async () => {
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)}
/>
))}
</div>
</>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
{/* 仓库管理弹窗 */}
{repoManagerOpen && (
<RepoManager onClose={() => setRepoManagerOpen(false)} />
)}
{/* 组件详情弹窗 */}
{detailComponent && detailComponent.id !== null && (
<ComponentDetail
componentId={detailComponent.id}
selectedApp={activeApp}
onClose={() => setDetailComponent(undefined)}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { TemplatesPage } from "./TemplatesPage";
export { ComponentCard } from "./ComponentCard";
export { ComponentDetail } from "./ComponentDetail";
export { CategoryFilter } from "./CategoryFilter";
export { RepoManager } from "./RepoManager";
+1 -1
View File
@@ -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",
-5
View File
@@ -115,11 +115,6 @@ export function useProviderActions(activeApp: AppId) {
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
await queryClient.invalidateQueries({
queryKey: ["usage", provider.id, activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
+83 -1
View File
@@ -584,7 +584,6 @@
"testFailed": "Test failed",
"formatSuccess": "Format successful",
"formatFailed": "Format failed",
"supportedVariables": "Supported Variables",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration",
"extractorCode": "Extractor code",
@@ -1047,6 +1046,89 @@
"circuitOpen": "Circuit Open",
"consecutiveFailures": "{{count}} consecutive failures"
},
"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": {
"panel": {
"serviceAddress": "Service Address",
+83 -1
View File
@@ -584,7 +584,6 @@
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"supportedVariables": "使用可能な変数",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
@@ -1040,6 +1039,89 @@
"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": "バンドルのインストールに失敗しました"
}
},
"health": {
"operational": "正常",
"degraded": "低下",
+83 -1
View File
@@ -584,7 +584,6 @@
"testFailed": "测试失败",
"formatSuccess": "格式化成功",
"formatFailed": "格式化失败",
"supportedVariables": "支持的变量",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置",
"extractorCode": "提取器代码",
@@ -1047,6 +1046,89 @@
"circuitOpen": "熔断",
"consecutiveFailures": "连续失败 {{count}} 次"
},
"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": {
"panel": {
"serviceAddress": "服务地址",
+102
View File
@@ -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<TemplateRepo[]> {
return invoke("list_template_repos");
},
async addTemplateRepo(
owner: string,
name: string,
branch: string,
): Promise<void> {
return invoke("add_template_repo", { owner, name, branch });
},
async removeTemplateRepo(id: number): Promise<void> {
return invoke("remove_template_repo", { id });
},
async toggleTemplateRepo(id: number, enabled: boolean): Promise<void> {
return invoke("toggle_template_repo", { id, enabled });
},
// 模板索引刷新
async refreshTemplateIndex(): Promise<void> {
return invoke("refresh_template_index");
},
// 模板组件查询
async listTemplateComponents(
filter: ComponentFilter,
): Promise<PaginatedResult<TemplateComponent>> {
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<ComponentDetail> {
return invoke("get_template_component", { id });
},
async getComponentCategories(
componentType?: ComponentType,
): Promise<string[]> {
return invoke("list_template_categories", { componentType });
},
// 组件安装管理
async installTemplateComponent(id: number, appType: AppType): Promise<void> {
return invoke("install_template_component", { id, appType });
},
async uninstallTemplateComponent(
id: number,
appType: AppType,
): Promise<void> {
return invoke("uninstall_template_component", { id, appType });
},
async batchInstallComponents(
ids: number[],
appType: AppType,
): Promise<BatchInstallResult> {
return invoke("batch_install_template_components", { ids, appType });
},
async listInstalledComponents(
appType?: AppType,
componentType?: ComponentType,
): Promise<InstalledComponent[]> {
return invoke("list_installed_components", { appType, componentType });
},
// 组件内容预览
async previewComponentContent(id: number): Promise<string> {
return invoke("preview_component_content", { id });
},
// 市场组合
async listMarketplaceBundles(): Promise<
import("@/types/template").MarketplaceBundle[]
> {
return invoke("list_marketplace_bundles");
},
};
-2
View File
@@ -28,7 +28,6 @@ export const usageApi = {
baseUrl?: string,
accessToken?: string,
userId?: string,
templateType?: "custom" | "general" | "newapi",
): Promise<UsageResult> => {
return invoke("testUsageScript", {
providerId,
@@ -39,7 +38,6 @@ export const usageApi = {
baseUrl,
accessToken,
userId,
templateType,
});
},
+177
View File
@@ -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,
});
}
-1
View File
@@ -52,7 +52,6 @@ export interface UsageScript {
language: "javascript"; // 脚本语言
code: string; // 脚本代码(JSON 格式配置)
timeout?: number; // 超时时间(秒,默认 10
templateType?: "custom" | "general" | "newapi"; // 模板类型(用于后端判断验证规则)
apiKey?: string; // 用量查询专用的 API Key(通用模板使用)
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
accessToken?: string; // 访问令牌(NewAPI 模板使用)
+104
View File
@@ -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<T> {
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[];
}