mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
@@ -12,7 +12,8 @@
|
||||
"Bash(find:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cargo clippy:*)"
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo fmt:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+191
-1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod mcp;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod skills;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SkillsPage } from "./SkillsPage";
|
||||
export { SkillCard } from "./SkillCard";
|
||||
export { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user