From 1f20ed34a9dd935d10eed867987db6d1169a582a Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 14 Dec 2025 21:58:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Skills=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=B5=8F=E8=A7=88=E5=92=8C?= =?UTF-8?q?=E5=AE=89=E8=A3=85=20Claude=20Skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .claude/settings.local.json | 3 +- package.json | 2 +- src-tauri/Cargo.lock | 192 ++++++++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/skill_cmd.rs | 194 +++++++++++ src-tauri/src/database/dao/mod.rs | 1 + src-tauri/src/database/dao/skills.rs | 138 ++++++++ src-tauri/src/database/schema.rs | 24 ++ src-tauri/src/lib.rs | 25 ++ src-tauri/src/models/mod.rs | 2 + src-tauri/src/models/skill_model.rs | 98 ++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/skill_service.rs | 362 +++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 6 +- src/components/Sidebar.tsx | 5 +- src/components/skills/RepoManagerPanel.tsx | 211 ++++++++++++ src/components/skills/SkillCard.tsx | 98 ++++++ src/components/skills/SkillsPage.tsx | 212 ++++++++++++ src/components/skills/index.ts | 3 + src/hooks/useSkills.ts | 70 ++++ src/lib/api/skills.ts | 51 +++ 23 files changed, 1700 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/commands/skill_cmd.rs create mode 100644 src-tauri/src/database/dao/skills.rs create mode 100644 src-tauri/src/models/skill_model.rs create mode 100644 src-tauri/src/services/skill_service.rs create mode 100644 src/components/skills/RepoManagerPanel.tsx create mode 100644 src/components/skills/SkillCard.tsx create mode 100644 src/components/skills/SkillsPage.tsx create mode 100644 src/components/skills/index.ts create mode 100644 src/hooks/useSkills.ts create mode 100644 src/lib/api/skills.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 66fc889dc..d62ec1bbc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(find:*)", "Bash(cargo check:*)", "Bash(rm:*)", - "Bash(cargo clippy:*)" + "Bash(cargo clippy:*)", + "Bash(cargo fmt:*)" ], "deny": [], "ask": [] diff --git a/package.json b/package.json index 5872f8244..38e4a3f7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.5.2", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 10165020e..387947429 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42efbb6d7..8d69ed96c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cc1321d8a..8f91ff9d2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/skill_cmd.rs b/src-tauri/src/commands/skill_cmd.rs new file mode 100644 index 000000000..ec804b9d7 --- /dev/null +++ b/src-tauri/src/commands/skill_cmd.rs @@ -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); + +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, 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, 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 { + 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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) +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index d489b7f38..2a93d1e1e 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -1,3 +1,4 @@ pub mod mcp; pub mod prompts; pub mod providers; +pub mod skills; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs new file mode 100644 index 000000000..406dbf770 --- /dev/null +++ b/src-tauri/src/database/dao/skills.rs @@ -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 { + 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, 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 { + 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) + } +} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index df74b05aa..3e58aaacb 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edb3da8e3..2b879ebb1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index bb2f4160a..f8e78341a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -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}; diff --git a/src-tauri/src/models/skill_model.rs b/src-tauri/src/models/skill_model.rs new file mode 100644 index 000000000..a36139421 --- /dev/null +++ b/src-tauri/src/models/skill_model.rs @@ -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, + pub installed: bool, + #[serde(rename = "repoOwner", skip_serializing_if = "Option::is_none")] + pub repo_owner: Option, + #[serde(rename = "repoName", skip_serializing_if = "Option::is_none")] + pub repo_name: Option, + #[serde(rename = "repoBranch", skip_serializing_if = "Option::is_none")] + pub repo_branch: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillMetadata { + pub name: Option, + pub description: Option, +} + +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 { + 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; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 785444786..aca3ef533 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/skill_service.rs b/src-tauri/src/services/skill_service.rs new file mode 100644 index 000000000..4e61b48db --- /dev/null +++ b/src-tauri/src/services/skill_service.rs @@ -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 { + 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 { + 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, + ) -> Result> { + let mut all_skills: HashMap = 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 = 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, + ) -> Result> { + 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 { + 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 { + 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) + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a4feb1a8e..e2ef26244 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index c9e38a14a..8f2829342 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("dashboard"); @@ -44,6 +46,8 @@ function App() { return ; case "prompts": return ; + case "skills": + return ; default: return ; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 143319c87..924ac99e9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 }, diff --git a/src/components/skills/RepoManagerPanel.tsx b/src/components/skills/RepoManagerPanel.tsx new file mode 100644 index 000000000..7352317b4 --- /dev/null +++ b/src/components/skills/RepoManagerPanel.tsx @@ -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; + onRemoveRepo: (owner: string, name: string) => Promise; + 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(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 ( +
+
+ {/* Header */} +
+
+

仓库管理

+

+ 管理 Skill 仓库源 +

+
+
+ + +
+
+ + {/* Content */} +
+ {/* Add Repo Form */} +
+

添加新仓库

+
+
+
+ + setOwner(e.target.value)} + placeholder="anthropics" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ + setName(e.target.value)} + placeholder="skills" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+
+ + setBranch(e.target.value)} + placeholder="main" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+ +
+
+ + {/* Repo List */} +
+

已添加的仓库

+ {repos.length === 0 ? ( +

+ 暂无仓库 +

+ ) : ( + repos.map((repo) => { + const key = `${repo.owner}/${repo.name}`; + const isRemoving = removing === key; + + return ( +
+
+

{key}

+

+ 分支: {repo.branch} +

+
+
+ + +
+
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx new file mode 100644 index 000000000..ce8a94400 --- /dev/null +++ b/src/components/skills/SkillCard.tsx @@ -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 ( +
+
+
+

{skill.name}

+ {skill.repoOwner && skill.repoName && ( +

+ {skill.repoOwner}/{skill.repoName} +

+ )} +
+ {skill.installed && ( + + 已安装 + + )} +
+ +

+ {skill.description || "暂无描述"} +

+ +
+ + + {skill.readmeUrl && ( + + )} +
+
+ ); +} diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx new file mode 100644 index 000000000..c5a70ccc0 --- /dev/null +++ b/src/components/skills/SkillsPage.tsx @@ -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( + ({ initialApp = "claude" }, ref) => { + const [app] = useState(initialApp); + const [searchQuery, setSearchQuery] = useState(""); + const [filterStatus, setFilterStatus] = useState< + "all" | "installed" | "uninstalled" + >("all"); + const [repoManagerOpen, setRepoManagerOpen] = useState(false); + const [installingSkills, setInstallingSkills] = useState>( + 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 ( +
+
+
+

Skills

+

浏览和安装 Claude Skills

+
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* 搜索和过滤 */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm" + /> +
+
+ + {/* 过滤标签 */} +
+ + + +
+ + {/* Skills 列表 */} + {loading ? ( +
+ +
+ ) : filteredSkills.length === 0 ? ( +
+

没有找到 skills

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ )} + + {/* 仓库管理面板 */} + {repoManagerOpen && ( + setRepoManagerOpen(false)} + onAddRepo={addRepo} + onRemoveRepo={removeRepo} + onRefresh={refresh} + /> + )} +
+ ); + }, +); + +SkillsPage.displayName = "SkillsPage"; diff --git a/src/components/skills/index.ts b/src/components/skills/index.ts new file mode 100644 index 000000000..a385bdaee --- /dev/null +++ b/src/components/skills/index.ts @@ -0,0 +1,3 @@ +export { SkillsPage } from "./SkillsPage"; +export { SkillCard } from "./SkillCard"; +export { RepoManagerPanel } from "./RepoManagerPanel"; diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts new file mode 100644 index 000000000..5509cd89f --- /dev/null +++ b/src/hooks/useSkills.ts @@ -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([]); + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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, + }; +} diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts new file mode 100644 index 000000000..63967d8c4 --- /dev/null +++ b/src/lib/api/skills.ts @@ -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 { + return invoke("get_skills_for_app", { app }); + }, + + async install(directory: string, app: AppType = "claude"): Promise { + return invoke("install_skill_for_app", { app, directory }); + }, + + async uninstall( + directory: string, + app: AppType = "claude", + ): Promise { + return invoke("uninstall_skill_for_app", { app, directory }); + }, + + async getRepos(): Promise { + return invoke("get_skill_repos"); + }, + + async addRepo(repo: SkillRepo): Promise { + return invoke("add_skill_repo", { repo }); + }, + + async removeRepo(owner: string, name: string): Promise { + return invoke("remove_skill_repo", { owner, name }); + }, +};