feat: 添加 Skills 功能,支持浏览和安装 Claude Skills

- 新增 Skills 页面,支持搜索、过滤和安装/卸载 Skills
- 支持从 GitHub 仓库获取 Skills(ComposioHQ/awesome-claude-skills, anthropics/skills, cexll/myclaude)
- 新增仓库管理面板,可添加/删除自定义仓库源
- 后端实现 SkillService 处理下载、安装、卸载逻辑
- 新增数据库表 skills 和 skill_repos 持久化状态
- 版本更新至 0.6.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2025-12-14 21:58:04 +08:00
co-authored by Claude Opus 4.5
parent ca5de5fed4
commit 1f20ed34a9
23 changed files with 1700 additions and 7 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"Bash(find:*)",
"Bash(cargo check:*)",
"Bash(rm:*)",
"Bash(cargo clippy:*)"
"Bash(cargo clippy:*)",
"Bash(cargo fmt:*)"
],
"deny": [],
"ask": []
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.5.2",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+191 -1
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -205,6 +216,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -295,6 +312,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -369,6 +406,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -425,6 +464,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -435,6 +484,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -647,6 +702,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1429,6 +1485,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1741,6 +1806,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1827,6 +1901,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.83"
@@ -2583,12 +2667,35 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"hmac",
"password-hash",
"sha2",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2873,19 +2980,22 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"anyhow",
"async-stream",
"axum",
"chrono",
"dirs 5.0.1",
"futures",
"indexmap 2.12.1",
"md5",
"regex",
"reqwest",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -2895,6 +3005,7 @@ dependencies = [
"tracing-subscriber",
"urlencoding",
"uuid",
"zip",
]
[[package]]
@@ -3501,6 +3612,19 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.12.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -3533,6 +3657,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4610,6 +4745,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5643,3 +5784,52 @@ dependencies = [
"quote",
"syn 2.0.111",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"aes",
"byteorder",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"flate2",
"hmac",
"pbkdf2",
"sha1",
"time",
"zstd",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "5.0.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
dependencies = [
"libc",
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.5.2"
version = "0.6.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -32,6 +32,10 @@ regex = "1"
md5 = "0.7"
urlencoding = "2"
rusqlite = { version = "0.31", features = ["bundled"] }
serde_yaml = "0.9"
indexmap = { version = "2", features = ["serde"] }
zip = "0.6"
anyhow = "1"
[features]
default = ["custom-protocol"]
+1
View File
@@ -2,4 +2,5 @@ pub mod config_cmd;
pub mod mcp_cmd;
pub mod oauth_cmd;
pub mod prompt_cmd;
pub mod skill_cmd;
pub mod switch_cmd;
+194
View File
@@ -0,0 +1,194 @@
use crate::database::dao::skills::SkillDao;
use crate::database::DbConnection;
use crate::models::{AppType, Skill, SkillRepo, SkillState};
use crate::services::skill_service::SkillService;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
pub struct SkillServiceState(pub Arc<SkillService>);
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
format!("{}:{}", app_type.to_string().to_lowercase(), directory)
}
#[tauri::command]
pub async fn get_skills(
db: State<'_, DbConnection>,
skill_service: State<'_, SkillServiceState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app(db, skill_service, "claude".to_string()).await
}
#[tauri::command]
pub async fn get_skills_for_app(
db: State<'_, DbConnection>,
skill_service: State<'_, SkillServiceState>,
app: String,
) -> Result<Vec<Skill>, String> {
let app_type: AppType = app.parse().map_err(|e: String| e)?;
// 获取仓库列表和已安装状态(在 await 之前完成)
let (repos, installed_states) = {
let conn = db.lock().map_err(|e| e.to_string())?;
let repos = SkillDao::get_skill_repos(&conn).map_err(|e| e.to_string())?;
let installed_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?;
(repos, installed_states)
};
// 获取技能列表
let skills = skill_service
.0
.list_skills(&app_type, &repos, &installed_states)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
{
let conn = db.lock().map_err(|e| e.to_string())?;
let existing_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?;
for skill in &skills {
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
let state = SkillState {
installed: true,
installed_at: Utc::now(),
};
SkillDao::update_skill_state(&conn, &key, &state).map_err(|e| e.to_string())?;
}
}
}
}
Ok(skills)
}
#[tauri::command]
pub async fn install_skill(
db: State<'_, DbConnection>,
skill_service: State<'_, SkillServiceState>,
directory: String,
) -> Result<bool, String> {
install_skill_for_app(db, skill_service, "claude".to_string(), directory).await
}
#[tauri::command]
pub async fn install_skill_for_app(
db: State<'_, DbConnection>,
skill_service: State<'_, SkillServiceState>,
app: String,
directory: String,
) -> Result<bool, String> {
let app_type: AppType = app.parse().map_err(|e: String| e)?;
// 获取技能信息(在 await 之前完成)
let (repos, installed_states) = {
let conn = db.lock().map_err(|e| e.to_string())?;
let repos = SkillDao::get_skill_repos(&conn).map_err(|e| e.to_string())?;
let installed_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?;
(repos, installed_states)
};
let skills = skill_service
.0
.list_skills(&app_type, &repos, &installed_states)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory == directory)
.ok_or_else(|| format!("Skill not found: {}", directory))?;
let repo_owner = skill
.repo_owner
.as_ref()
.ok_or_else(|| "Missing repo owner".to_string())?
.clone();
let repo_name = skill
.repo_name
.as_ref()
.ok_or_else(|| "Missing repo name".to_string())?
.clone();
let repo_branch = skill
.repo_branch
.as_ref()
.ok_or_else(|| "Missing repo branch".to_string())?
.clone();
// 安装技能
skill_service
.0
.install_skill(&app_type, &repo_owner, &repo_name, &repo_branch, &directory)
.await
.map_err(|e| e.to_string())?;
// 更新数据库
let key = get_skill_key(&app_type, &directory);
let state = SkillState {
installed: true,
installed_at: Utc::now(),
};
{
let conn = db.lock().map_err(|e| e.to_string())?;
SkillDao::update_skill_state(&conn, &key, &state).map_err(|e| e.to_string())?;
}
Ok(true)
}
#[tauri::command]
pub fn uninstall_skill(db: State<'_, DbConnection>, directory: String) -> Result<bool, String> {
uninstall_skill_for_app(db, "claude".to_string(), directory)
}
#[tauri::command]
pub fn uninstall_skill_for_app(
db: State<'_, DbConnection>,
app: String,
directory: String,
) -> Result<bool, String> {
let app_type: AppType = app.parse().map_err(|e: String| e)?;
// 卸载技能
SkillService::uninstall_skill(&app_type, &directory).map_err(|e| e.to_string())?;
// 更新数据库
let key = get_skill_key(&app_type, &directory);
let state = SkillState {
installed: false,
installed_at: Utc::now(),
};
let conn = db.lock().map_err(|e| e.to_string())?;
SkillDao::update_skill_state(&conn, &key, &state).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn get_skill_repos(db: State<'_, DbConnection>) -> Result<Vec<SkillRepo>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
SkillDao::get_skill_repos(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_skill_repo(db: State<'_, DbConnection>, repo: SkillRepo) -> Result<bool, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
SkillDao::save_skill_repo(&conn, &repo).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn remove_skill_repo(
db: State<'_, DbConnection>,
owner: String,
name: String,
) -> Result<bool, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
SkillDao::delete_skill_repo(&conn, &owner, &name).map_err(|e| e.to_string())?;
Ok(true)
}
+1
View File
@@ -1,3 +1,4 @@
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod skills;
+138
View File
@@ -0,0 +1,138 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
use crate::models::{SkillRepo, SkillState, SkillStates};
use chrono::DateTime;
use rusqlite::{params, Connection};
use std::collections::HashMap;
pub struct SkillDao;
impl SkillDao {
/// 获取所有 Skills 状态
pub fn get_skills(conn: &Connection) -> Result<SkillStates, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC",
)?;
let skill_iter = stmt.query_map([], |row| {
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at = DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key:"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
installed,
installed_at,
},
))
})?;
let mut skills = HashMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res?;
skills.insert(key, skill);
}
Ok(skills)
}
/// 更新 Skill 状态
pub fn update_skill_state(
conn: &Connection,
key: &str,
state: &SkillState,
) -> Result<(), rusqlite::Error> {
// 解析 key: "app_type:directory"
let parts: Vec<&str> = key.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(rusqlite::Error::InvalidQuery);
}
let app_type = parts[0];
let directory = parts[1];
conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at)
VALUES (?1, ?2, ?3, ?4)",
params![
directory,
app_type,
state.installed,
state.installed_at.timestamp()
],
)?;
Ok(())
}
/// 获取所有 Skill 仓库
pub fn get_skill_repos(conn: &Connection) -> Result<Vec<SkillRepo>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
)?;
let repo_iter = stmt.query_map([], |row| {
Ok(SkillRepo {
owner: row.get(0)?,
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
})
})?;
let mut repos = Vec::new();
for repo_res in repo_iter {
repos.push(repo_res?);
}
Ok(repos)
}
/// 保存 Skill 仓库
pub fn save_skill_repo(conn: &Connection, repo: &SkillRepo) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled)
VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
)?;
Ok(())
}
/// 删除 Skill 仓库
pub fn delete_skill_repo(
conn: &Connection,
owner: &str,
name: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
params![owner, name],
)?;
Ok(())
}
/// 初始化默认 Skill 仓库
pub fn init_default_skill_repos(conn: &Connection) -> Result<usize, rusqlite::Error> {
use crate::models::skill_model::get_default_skill_repos;
let existing_repos = Self::get_skill_repos(conn)?;
if !existing_repos.is_empty() {
return Ok(0);
}
let default_repos = get_default_skill_repos();
let mut count = 0;
for repo in default_repos {
Self::save_skill_repo(conn, &repo)?;
count += 1;
}
Ok(count)
}
}
+24
View File
@@ -70,5 +70,29 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL,
app_type TEXT NOT NULL,
installed INTEGER NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
[],
)?;
// Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
)",
[],
)?;
Ok(())
}
+25
View File
@@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use commands::skill_cmd::SkillServiceState;
use services::skill_service::SkillService;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderType {
@@ -1262,6 +1265,17 @@ pub fn run() {
// Initialize database for Switch functionality
let db = database::init_database().expect("Failed to initialize database");
// Initialize SkillService
let skill_service = SkillService::new().expect("Failed to initialize SkillService");
let skill_service_state = SkillServiceState(Arc::new(skill_service));
// Initialize default skill repos
{
let conn = db.lock().expect("Failed to lock database");
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
.expect("Failed to initialize default skill repos");
}
// Clone for setup hook
let state_clone = state.clone();
let logs_clone = logs.clone();
@@ -1275,6 +1289,7 @@ pub fn run() {
.manage(state)
.manage(logs)
.manage(db)
.manage(skill_service_state)
.setup(move |_app| {
// 自动启动服务器
let state = state_clone.clone();
@@ -1400,6 +1415,16 @@ pub fn run() {
commands::prompt_cmd::get_current_prompt_file_content,
commands::prompt_cmd::auto_import_prompt,
commands::prompt_cmd::switch_prompt,
// Skill commands
commands::skill_cmd::get_skills,
commands::skill_cmd::get_skills_for_app,
commands::skill_cmd::install_skill,
commands::skill_cmd::install_skill_for_app,
commands::skill_cmd::uninstall_skill,
commands::skill_cmd::uninstall_skill_for_app,
commands::skill_cmd::get_skill_repos,
commands::skill_cmd::add_skill_repo,
commands::skill_cmd::remove_skill_repo,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+2
View File
@@ -5,6 +5,7 @@ pub mod mcp_model;
pub mod openai;
pub mod prompt_model;
pub mod provider_model;
pub mod skill_model;
#[allow(unused_imports)]
pub use anthropic::*;
@@ -16,3 +17,4 @@ pub use mcp_model::McpServer;
pub use openai::*;
pub use prompt_model::Prompt;
pub use provider_model::Provider;
pub use skill_model::{Skill, SkillMetadata, SkillRepo, SkillState, SkillStates};
+98
View File
@@ -0,0 +1,98 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
pub key: String,
pub name: String,
pub description: String,
pub directory: String,
#[serde(rename = "readmeUrl", skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
pub installed: bool,
#[serde(rename = "repoOwner", skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
#[serde(rename = "repoName", skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
#[serde(rename = "repoBranch", skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillRepo {
pub owner: String,
pub name: String,
pub branch: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillState {
pub installed: bool,
pub installed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
pub name: Option<String>,
pub description: Option<String>,
}
impl Default for SkillRepo {
fn default() -> Self {
Self {
owner: String::new(),
name: String::new(),
branch: "main".to_string(),
enabled: true,
}
}
}
impl SkillRepo {
pub fn new(owner: String, name: String, branch: String) -> Self {
Self {
owner,
name,
branch,
enabled: true,
}
}
pub fn github_url(&self) -> String {
format!("https://github.com/{}/{}", self.owner, self.name)
}
pub fn zip_url(&self) -> String {
format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
self.owner, self.name, self.branch
)
}
}
pub fn get_default_skill_repos() -> Vec<SkillRepo> {
vec![
SkillRepo {
owner: "ComposioHQ".to_string(),
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
},
]
}
pub type SkillStates = HashMap<String, SkillState>;
+1
View File
@@ -3,4 +3,5 @@ pub mod mcp_service;
pub mod mcp_sync;
pub mod prompt_service;
pub mod prompt_sync;
pub mod skill_service;
pub mod switch;
+362
View File
@@ -0,0 +1,362 @@
use anyhow::{anyhow, Context, Result};
use reqwest::Client;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use crate::models::{AppType, Skill, SkillMetadata, SkillRepo, SkillState};
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
pub struct SkillService {
client: Client,
}
impl SkillService {
pub fn new() -> Result<Self> {
let client = Client::builder()
.timeout(DOWNLOAD_TIMEOUT)
.build()
.context("Failed to create HTTP client")?;
Ok(Self { client })
}
/// 获取技能安装目录
fn get_skills_dir(app_type: &AppType) -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("Failed to get home directory"))?;
let skills_dir = match app_type {
AppType::Claude => home.join(".claude").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::ProxyCast => home.join(".proxycast").join("skills"),
};
Ok(skills_dir)
}
/// 列出所有技能
pub async fn list_skills(
&self,
app_type: &AppType,
repos: &[SkillRepo],
installed_states: &HashMap<String, SkillState>,
) -> Result<Vec<Skill>> {
let mut all_skills: HashMap<String, Skill> = HashMap::new();
// 1. 从启用的仓库获取技能
let enabled_repos: Vec<_> = repos.iter().filter(|r| r.enabled).collect();
for repo in enabled_repos {
match timeout(
DOWNLOAD_TIMEOUT,
self.fetch_skills_from_repo(repo, app_type, installed_states),
)
.await
{
Ok(Ok(skills)) => {
for skill in skills {
all_skills.insert(skill.key.clone(), skill);
}
}
Ok(Err(e)) => {
tracing::warn!(
"Failed to fetch skills from {}/{}: {}",
repo.owner,
repo.name,
e
);
}
Err(_) => {
tracing::warn!("Timeout fetching skills from {}/{}", repo.owner, repo.name);
}
}
}
// 2. 添加本地已安装但不在任何仓库中的技能
let skills_dir = Self::get_skills_dir(app_type)?;
if skills_dir.exists() {
if let Ok(entries) = fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let directory = entry.file_name().to_string_lossy().to_string();
// 检查是否已有相同 directory 的 skill(按 directory 去重)
let already_exists = all_skills.values().any(|s| s.directory == directory);
if !already_exists {
let key = format!("local:{}", directory);
let skill_md = entry.path().join("SKILL.md");
let (name, description) = if skill_md.exists() {
self.parse_skill_metadata(&skill_md)
.map(|m| {
(
m.name.unwrap_or_else(|| directory.clone()),
m.description.unwrap_or_default(),
)
})
.unwrap_or_else(|_| (directory.clone(), String::new()))
} else {
(directory.clone(), String::new())
};
all_skills.insert(
key.clone(),
Skill {
key,
name,
description,
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
},
);
}
}
}
}
}
// 3. 排序并返回
let mut skills: Vec<Skill> = all_skills.into_values().collect();
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}
/// 从仓库获取技能列表
async fn fetch_skills_from_repo(
&self,
repo: &SkillRepo,
app_type: &AppType,
installed_states: &HashMap<String, SkillState>,
) -> Result<Vec<Skill>> {
let zip_url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo.owner, repo.name, repo.branch
);
// 下载 ZIP
let response = self
.client
.get(&zip_url)
.send()
.await
.context("Failed to download repository")?;
if !response.status().is_success() {
return Err(anyhow!("HTTP {}: {}", response.status(), zip_url));
}
let bytes = response.bytes().await.context("Failed to read response")?;
// 解压并扫描
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open ZIP archive")?;
let mut skills = Vec::new();
let repo_key_prefix = format!("{}/{}:", repo.owner, repo.name);
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("Failed to read ZIP entry")?;
let file_path = file.name().to_string();
if file_path.ends_with("/SKILL.md") || file_path.ends_with("\\SKILL.md") {
let path = Path::new(&file_path);
let directory = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// 读取并解析 SKILL.md
let mut content = String::new();
use std::io::Read;
file.read_to_string(&mut content)
.context("Failed to read SKILL.md")?;
let metadata = self.parse_skill_metadata_from_content(&content)?;
let name = metadata.name.unwrap_or_else(|| directory.clone());
let description = metadata.description.unwrap_or_default();
let key = format!("{}{}", repo_key_prefix, directory);
let app_key = format!("{}:{}", app_type.to_string().to_lowercase(), directory);
let installed = installed_states.contains_key(&app_key);
let readme_url = Some(format!(
"https://github.com/{}/{}/blob/{}/{}/SKILL.md",
repo.owner,
repo.name,
repo.branch,
path.parent().unwrap().to_str().unwrap_or("")
));
skills.push(Skill {
key,
name,
description,
directory,
readme_url,
installed,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
});
}
}
Ok(skills)
}
/// 安装技能
pub async fn install_skill(
&self,
app_type: &AppType,
repo_owner: &str,
repo_name: &str,
repo_branch: &str,
directory: &str,
) -> Result<()> {
let skills_dir = Self::get_skills_dir(app_type)?;
fs::create_dir_all(&skills_dir).context("Failed to create skills directory")?;
let target_dir = skills_dir.join(directory);
if target_dir.exists() {
fs::remove_dir_all(&target_dir).context("Failed to remove existing skill")?;
}
// 尝试多个分支
let branches = if repo_branch == "main" {
vec!["main", "master"]
} else {
vec![repo_branch]
};
let mut last_error = None;
for branch in branches {
let zip_url = format!(
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
repo_owner, repo_name, branch
);
match self
.download_and_extract(&zip_url, &target_dir, directory)
.await
{
Ok(_) => return Ok(()),
Err(e) => {
last_error = Some(e);
continue;
}
}
}
Err(last_error.unwrap_or_else(|| anyhow!("Failed to install skill")))
}
/// 下载并解压技能
async fn download_and_extract(
&self,
zip_url: &str,
target_dir: &Path,
directory: &str,
) -> Result<()> {
let response = self
.client
.get(zip_url)
.send()
.await
.context("Failed to download")?;
if !response.status().is_success() {
return Err(anyhow!("HTTP {}", response.status()));
}
let bytes = response.bytes().await.context("Failed to read response")?;
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open ZIP")?;
// 查找技能目录
let skill_prefix = format!("/{}/", directory);
let mut found = false;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name().to_string();
if file_path.contains(&skill_prefix) {
found = true;
let relative_path = file_path
.split(&skill_prefix)
.nth(1)
.unwrap_or("")
.to_string();
if !relative_path.is_empty() {
let output_path = target_dir.join(&relative_path);
if file.is_dir() {
fs::create_dir_all(&output_path)?;
} else {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut output_file = fs::File::create(&output_path)?;
std::io::copy(&mut file, &mut output_file)?;
}
}
}
}
if !found {
return Err(anyhow!("Skill directory not found in archive"));
}
Ok(())
}
/// 卸载技能
pub fn uninstall_skill(app_type: &AppType, directory: &str) -> Result<()> {
let skills_dir = Self::get_skills_dir(app_type)?;
let target_dir = skills_dir.join(directory);
if target_dir.exists() {
fs::remove_dir_all(&target_dir).context("Failed to remove skill directory")?;
}
Ok(())
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path).context("Failed to read SKILL.md")?;
self.parse_skill_metadata_from_content(&content)
}
/// 从内容解析技能元数据
fn parse_skill_metadata_from_content(&self, content: &str) -> Result<SkillMetadata> {
let content = content.trim_start_matches('\u{feff}');
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Ok(SkillMetadata {
name: None,
description: None,
});
}
let front_matter = parts[1].trim();
let meta: SkillMetadata =
serde_yaml::from_str(front_matter).context("Failed to parse YAML front matter")?;
Ok(meta)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.4.3",
"version": "0.6.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+5 -1
View File
@@ -9,6 +9,7 @@ import { McpPage } from "./components/mcp";
import { PromptsPage } from "./components/prompts";
import { CredentialsPage } from "./components/credentials/CredentialsPage";
import { ApiServerPage } from "./components/api-server/ApiServerPage";
import { SkillsPage } from "./components/skills";
type Page =
| "dashboard"
@@ -19,7 +20,8 @@ type Page =
| "settings"
| "switch"
| "mcp"
| "prompts";
| "prompts"
| "skills";
function App() {
const [currentPage, setCurrentPage] = useState<Page>("dashboard");
@@ -44,6 +46,8 @@ function App() {
return <McpPage />;
case "prompts":
return <PromptsPage />;
case "skills":
return <SkillsPage />;
default:
return <Dashboard />;
}
+4 -1
View File
@@ -6,6 +6,7 @@ import {
Key,
Monitor,
Globe,
Boxes,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -18,7 +19,8 @@ type Page =
| "settings"
| "switch"
| "mcp"
| "prompts";
| "prompts"
| "skills";
interface SidebarProps {
currentPage: Page;
@@ -32,6 +34,7 @@ const navItems = [
{ id: "api-server" as Page, label: "API Server", icon: Globe },
{ id: "mcp" as Page, label: "MCP", icon: Plug },
{ id: "prompts" as Page, label: "Prompts", icon: MessageSquare },
{ id: "skills" as Page, label: "Skills", icon: Boxes },
{ id: "settings" as Page, label: "设置", icon: Settings },
// Legacy pages (hidden but accessible)
// { id: "providers" as Page, label: "Provider (旧)", icon: Server },
+211
View File
@@ -0,0 +1,211 @@
import { useState } from "react";
import { X, Plus, Trash2, ExternalLink, RefreshCw } from "lucide-react";
import type { SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
onClose: () => void;
onAddRepo: (repo: SkillRepo) => Promise<void>;
onRemoveRepo: (owner: string, name: string) => Promise<void>;
onRefresh: () => void;
}
export function RepoManagerPanel({
repos,
onClose,
onAddRepo,
onRemoveRepo,
onRefresh,
}: RepoManagerPanelProps) {
const [owner, setOwner] = useState("");
const [name, setName] = useState("");
const [branch, setBranch] = useState("main");
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const handleAdd = async () => {
if (!owner.trim() || !name.trim()) {
alert("请输入仓库所有者和名称");
return;
}
setAdding(true);
try {
await onAddRepo({
owner: owner.trim(),
name: name.trim(),
branch: branch.trim() || "main",
enabled: true,
});
setOwner("");
setName("");
setBranch("main");
} catch (e) {
alert(`添加失败: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setAdding(false);
}
};
const handleRemove = async (repoOwner: string, repoName: string) => {
const key = `${repoOwner}/${repoName}`;
setRemoving(key);
try {
await onRemoveRepo(repoOwner, repoName);
} catch (e) {
alert(`删除失败: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setRemoving(null);
}
};
const openRepo = (repo: SkillRepo) => {
window.open(`https://github.com/${repo.owner}/${repo.name}`, "_blank");
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl shadow-lg w-full max-w-2xl max-h-[80vh] overflow-hidden border">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div>
<h3 className="text-lg font-semibold">仓库管理</h3>
<p className="text-sm text-muted-foreground">
管理 Skill 仓库源
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="rounded-lg p-2 hover:bg-muted"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={onClose}
className="rounded-lg p-2 hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(80vh-140px)]">
{/* Add Repo Form */}
<div className="rounded-lg border bg-card p-4">
<h4 className="font-medium mb-3">添加新仓库</h4>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-muted-foreground mb-1">
所有者
</label>
<input
type="text"
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="anthropics"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
仓库名
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="skills"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
分支(可选)
</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<button
onClick={handleAdd}
disabled={adding}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{adding ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
添加中...
</>
) : (
<>
<Plus className="h-4 w-4" />
添加仓库
</>
)}
</button>
</div>
</div>
{/* Repo List */}
<div className="space-y-2">
<h4 className="font-medium">已添加的仓库</h4>
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
暂无仓库
</p>
) : (
repos.map((repo) => {
const key = `${repo.owner}/${repo.name}`;
const isRemoving = removing === key;
return (
<div
key={key}
className="flex items-center justify-between rounded-lg border bg-card p-3"
>
<div className="flex-1">
<p className="font-medium">{key}</p>
<p className="text-xs text-muted-foreground">
分支: {repo.branch}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openRepo(repo)}
className="rounded-lg p-2 hover:bg-muted"
title="在 GitHub 上查看"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
onClick={() => handleRemove(repo.owner, repo.name)}
disabled={isRemoving}
className="rounded-lg p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 disabled:opacity-50"
title="删除"
>
{isRemoving ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Download, Trash2, ExternalLink, Loader2 } from "lucide-react";
import type { Skill } from "@/lib/api/skills";
interface SkillCardProps {
skill: Skill;
onInstall: (directory: string) => void;
onUninstall: (directory: string) => void;
installing: boolean;
}
export function SkillCard({
skill,
onInstall,
onUninstall,
installing,
}: SkillCardProps) {
const handleAction = () => {
if (installing) return;
if (skill.installed) {
onUninstall(skill.directory);
} else {
onInstall(skill.directory);
}
};
const openGithub = () => {
if (skill.readmeUrl) {
window.open(skill.readmeUrl, "_blank");
}
};
return (
<div className="rounded-lg border bg-card p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-lg mb-1">{skill.name}</h3>
{skill.repoOwner && skill.repoName && (
<p className="text-xs text-muted-foreground">
{skill.repoOwner}/{skill.repoName}
</p>
)}
</div>
{skill.installed && (
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
已安装
</span>
)}
</div>
<p className="text-sm text-muted-foreground mb-4 line-clamp-3">
{skill.description || "暂无描述"}
</p>
<div className="flex items-center gap-2">
<button
onClick={handleAction}
disabled={installing}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
skill.installed
? "border border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
: "bg-primary text-primary-foreground hover:bg-primary/90"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{installing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{skill.installed ? "卸载中..." : "安装中..."}
</>
) : (
<>
{skill.installed ? (
<>
<Trash2 className="h-4 w-4" />
卸载
</>
) : (
<>
<Download className="h-4 w-4" />
安装
</>
)}
</>
)}
</button>
{skill.readmeUrl && (
<button
onClick={openGithub}
className="rounded-lg border p-2 hover:bg-muted"
title="在 GitHub 上查看"
>
<ExternalLink className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, forwardRef, useImperativeHandle } from "react";
import { RefreshCw, Search, Settings } from "lucide-react";
import { useSkills } from "@/hooks/useSkills";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import type { AppType } from "@/lib/api/skills";
interface SkillsPageProps {
initialApp?: AppType;
}
export interface SkillsPageRef {
refresh: () => void;
openRepoManager: () => void;
}
export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
const [app] = useState<AppType>(initialApp);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled"
>("all");
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [installingSkills, setInstallingSkills] = useState<Set<string>>(
new Set(),
);
const {
skills,
repos,
loading,
error,
refresh,
install,
uninstall,
addRepo,
removeRepo,
} = useSkills(app);
useImperativeHandle(ref, () => ({
refresh,
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
setInstallingSkills((prev) => new Set(prev).add(directory));
try {
await install(directory);
} catch (e) {
alert(`安装失败: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setInstallingSkills((prev) => {
const next = new Set(prev);
next.delete(directory);
return next;
});
}
};
const handleUninstall = async (directory: string) => {
setInstallingSkills((prev) => new Set(prev).add(directory));
try {
await uninstall(directory);
} catch (e) {
alert(`卸载失败: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setInstallingSkills((prev) => {
const next = new Set(prev);
next.delete(directory);
return next;
});
}
};
const filteredSkills = skills.filter((skill) => {
const matchesSearch =
skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesFilter =
filterStatus === "all" ||
(filterStatus === "installed" && skill.installed) ||
(filterStatus === "uninstalled" && !skill.installed);
return matchesSearch && matchesFilter;
});
const installedCount = skills.filter((s) => s.installed).length;
const uninstalledCount = skills.length - installedCount;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Skills</h2>
<p className="text-muted-foreground">浏览和安装 Claude Skills</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
刷新
</button>
<button
onClick={() => setRepoManagerOpen(true)}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<Settings className="h-4 w-4" />
仓库管理
</button>
</div>
</div>
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700 dark:bg-red-950/30">
{error}
</div>
)}
{/* 搜索和过滤 */}
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="搜索 skills..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm"
/>
</div>
</div>
{/* 过滤标签 */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setFilterStatus("all")}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
filterStatus === "all"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
全部 ({skills.length})
</button>
<button
onClick={() => setFilterStatus("installed")}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
filterStatus === "installed"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
已安装 ({installedCount})
</button>
<button
onClick={() => setFilterStatus("uninstalled")}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
filterStatus === "uninstalled"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
未安装 ({uninstalledCount})
</button>
</div>
{/* Skills 列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p>没有找到 skills</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
installing={installingSkills.has(skill.directory)}
/>
))}
</div>
)}
{/* 仓库管理面板 */}
{repoManagerOpen && (
<RepoManagerPanel
repos={repos}
onClose={() => setRepoManagerOpen(false)}
onAddRepo={addRepo}
onRemoveRepo={removeRepo}
onRefresh={refresh}
/>
)}
</div>
);
},
);
SkillsPage.displayName = "SkillsPage";
+3
View File
@@ -0,0 +1,3 @@
export { SkillsPage } from "./SkillsPage";
export { SkillCard } from "./SkillCard";
export { RepoManagerPanel } from "./RepoManagerPanel";
+70
View File
@@ -0,0 +1,70 @@
import { useState, useEffect, useCallback } from "react";
import { skillsApi, Skill, SkillRepo, AppType } from "@/lib/api/skills";
export function useSkills(app: AppType = "claude") {
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSkills = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await skillsApi.getAll(app);
setSkills(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [app]);
const fetchRepos = useCallback(async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (e) {
console.error("Failed to fetch repos:", e);
}
}, []);
useEffect(() => {
fetchSkills();
fetchRepos();
}, [fetchSkills, fetchRepos]);
const install = async (directory: string) => {
await skillsApi.install(directory, app);
await fetchSkills();
};
const uninstall = async (directory: string) => {
await skillsApi.uninstall(directory, app);
await fetchSkills();
};
const addRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
await fetchRepos();
await fetchSkills();
};
const removeRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
await fetchRepos();
await fetchSkills();
};
return {
skills,
repos,
loading,
error,
refresh: fetchSkills,
install,
uninstall,
addRepo,
removeRepo,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { invoke } from "@tauri-apps/api/core";
export interface Skill {
key: string;
name: string;
description: string;
directory: string;
readmeUrl?: string;
installed: boolean;
repoOwner?: string;
repoName?: string;
repoBranch?: string;
}
export interface SkillRepo {
owner: string;
name: string;
branch: string;
enabled: boolean;
}
export type AppType = "claude" | "codex" | "gemini";
export const skillsApi = {
async getAll(app: AppType = "claude"): Promise<Skill[]> {
return invoke("get_skills_for_app", { app });
},
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
return invoke("install_skill_for_app", { app, directory });
},
async uninstall(
directory: string,
app: AppType = "claude",
): Promise<boolean> {
return invoke("uninstall_skill_for_app", { app, directory });
},
async getRepos(): Promise<SkillRepo[]> {
return invoke("get_skill_repos");
},
async addRepo(repo: SkillRepo): Promise<boolean> {
return invoke("add_skill_repo", { repo });
},
async removeRepo(owner: string, name: string): Promise<boolean> {
return invoke("remove_skill_repo", { owner, name });
},
};