Feat/claude skills management (#237)
* feat(skills): add Claude Skills management feature Implement complete Skills management system with repository discovery, installation, and lifecycle management capabilities. Backend: - Add SkillService with GitHub integration and installation logic - Implement skill commands (list, install, uninstall, check updates) - Support multiple skill repositories with caching Frontend: - Add Skills management page with repository browser - Create SkillCard and RepoManager components - Add badge, card, table UI components - Integrate Skills API with Tauri commands Files: 10 files changed, 1488 insertions(+) * feat(skills): integrate Skills feature into application Integrate Skills management feature with complete dependency updates, configuration structure extensions, and internationalization support. Dependencies: - Add @radix-ui/react-visually-hidden for accessibility - Add anyhow, zip, serde_yaml, tempfile for Skills backend - Enable chrono serde feature for timestamp serialization Backend Integration: - Extend MultiAppConfig with SkillStore field - Implement skills.json migration from legacy location - Register SkillService and skill commands in main app - Export skill module in commands and services Frontend Integration: - Add Skills page route and dialog in App - Integrate Skills UI with main navigation Internationalization: - Add complete Chinese translations for Skills UI - Add complete English translations for Skills UI Code Quality: - Remove redundant blank lines in gemini_mcp.rs - Format log statements in mcp.rs Tests: - Update import_export_sync tests for SkillStore - Update mcp_commands tests for new structure Files: 16 files changed, 540 insertions(+), 39 deletions(-) * style(skills): improve SkillsPage typography and spacing Optimize visual hierarchy and readability of Skills page: - Reduce title size from 2xl to lg with tighter tracking - Improve description spacing and color contrast - Enhance empty state with better text hierarchy - Use explicit gray colors for better dark mode support * feat(skills): support custom subdirectory path for skill scanning Add optional skillsPath field to SkillRepo to enable scanning skills from subdirectories (e.g., "skills/") instead of repository root. Changes: - Backend: Add skillsPath field with subdirectory scanning logic - Frontend: Add skillsPath input field and display in repo list - Presets: Add cexll/myclaude repo with skills/ subdirectory - Code quality: Fix clippy warnings (dedup logic, string formatting) Backward compatible: skillsPath is optional, defaults to root scanning. * refactor(skills): improve repo manager dialog layout Optimize dialog structure with fixed header and scrollable content: - Add flexbox layout with fixed header and scrollable body - Remove outer border wrapper for cleaner appearance - Match SkillsPage design pattern for consistency - Improve UX with better content hierarchy
This commit is contained in:
@@ -54,6 +54,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
|
|||||||
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
|||||||
'@radix-ui/react-tabs':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.13
|
specifier: ^1.1.13
|
||||||
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-visually-hidden':
|
||||||
|
specifier: ^1.2.4
|
||||||
|
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.13
|
||||||
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
|
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))
|
||||||
@@ -842,6 +845,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4':
|
||||||
|
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.11':
|
'@radix-ui/react-roving-focus@1.1.11':
|
||||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -877,6 +893,15 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4':
|
||||||
|
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-switch@1.2.6':
|
'@radix-ui/react-switch@1.2.6':
|
||||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -988,6 +1013,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.4':
|
||||||
|
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
@@ -3036,6 +3074,15 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3089,6 +3136,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3183,6 +3237,15 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|||||||
257
src-tauri/Cargo.lock
generated
257
src-tauri/Cargo.lock
generated
@@ -17,6 +17,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -484,6 +495,25 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bzip2"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||||
|
dependencies = [
|
||||||
|
"bzip2-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -558,6 +588,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
|
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -565,6 +597,7 @@ dependencies = [
|
|||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.6.2"
|
version = "3.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -576,6 +609,7 @@ dependencies = [
|
|||||||
"rquickjs",
|
"rquickjs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -591,6 +625,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -646,6 +681,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.0",
|
"windows-link 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -665,6 +710,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -730,6 +781,21 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||||
|
dependencies = [
|
||||||
|
"crc-catalog",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc-catalog"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -836,6 +902,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deflate64"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -878,6 +950,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1701,6 +1774,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -1994,6 +2076,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -2091,6 +2182,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
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.3",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.81"
|
||||||
@@ -2249,6 +2350,27 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-rs"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"crc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-sys"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2862,6 +2984,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -3979,6 +4111,19 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial_test"
|
name = "serial_test"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
@@ -4036,6 +4181,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -4631,7 +4787,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
"zip",
|
"zip 4.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5204,6 +5360,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -6230,6 +6392,15 @@ dependencies = [
|
|||||||
"rustix",
|
"rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xz2"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||||
|
dependencies = [
|
||||||
|
"lzma-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -6361,6 +6532,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@@ -6395,6 +6580,36 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"arbitrary",
|
||||||
|
"bzip2",
|
||||||
|
"constant_time_eq",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"deflate64",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"getrandom 0.3.3",
|
||||||
|
"hmac",
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"lzma-rs",
|
||||||
|
"memchr",
|
||||||
|
"pbkdf2",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"time",
|
||||||
|
"xz2",
|
||||||
|
"zeroize",
|
||||||
|
"zopfli",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -6407,6 +6622,46 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.7.0"
|
version = "5.7.0"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = "0.4"
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
@@ -42,6 +42,10 @@ futures = "0.3"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
zip = "2.2"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::services::skill::SkillStore;
|
||||||
|
|
||||||
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
pub struct McpApps {
|
pub struct McpApps {
|
||||||
@@ -221,6 +223,9 @@ pub struct MultiAppConfig {
|
|||||||
/// Prompt 配置(按客户端分治)
|
/// Prompt 配置(按客户端分治)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompts: PromptRoot,
|
pub prompts: PromptRoot,
|
||||||
|
/// Claude Skills 配置
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
/// 通用配置片段(按应用分治)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
@@ -245,6 +250,7 @@ impl Default for MultiAppConfig {
|
|||||||
apps,
|
apps,
|
||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
prompts: PromptRoot::default(),
|
prompts: PromptRoot::default(),
|
||||||
|
skills: SkillStore::default(),
|
||||||
common_config_snippets: CommonConfigSnippets::default(),
|
common_config_snippets: CommonConfigSnippets::default(),
|
||||||
claude_common_config_snippet: None,
|
claude_common_config_snippet: None,
|
||||||
}
|
}
|
||||||
@@ -288,11 +294,36 @@ impl MultiAppConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_skills_in_config = value
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|map| map.contains_key("skills"));
|
||||||
|
|
||||||
// 解析 v2 结构
|
// 解析 v2 结构
|
||||||
let mut config: Self =
|
let mut config: Self =
|
||||||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
let mut updated = false;
|
let mut updated = false;
|
||||||
|
|
||||||
|
if !has_skills_in_config {
|
||||||
|
let skills_path = get_app_config_dir().join("skills.json");
|
||||||
|
if skills_path.exists() {
|
||||||
|
match std::fs::read_to_string(&skills_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||||
|
Ok(store) => {
|
||||||
|
config.skills = store;
|
||||||
|
updated = true;
|
||||||
|
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||||
if !config.apps.contains_key("gemini") {
|
if !config.apps.contains_key("gemini") {
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod plugin;
|
|||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
pub mod skill;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
@@ -17,3 +18,4 @@ pub use plugin::*;
|
|||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
|
pub use skill::*;
|
||||||
|
|||||||
163
src-tauri/src/commands/skill.rs
Normal file
163
src-tauri/src/commands/skill.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::services::skill::SkillState;
|
||||||
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills(
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
|
let repos = {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
config.skills.repos.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let skills = service
|
||||||
|
.0
|
||||||
|
.list_skills(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skill = skills
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||||
|
.ok_or_else(|| "技能不存在".to_string())?;
|
||||||
|
|
||||||
|
if !skill.installed {
|
||||||
|
let repo = SkillRepo {
|
||||||
|
owner: skill
|
||||||
|
.repo_owner
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
name: skill
|
||||||
|
.repo_name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "缺少仓库信息".to_string())?,
|
||||||
|
branch: skill
|
||||||
|
.repo_branch
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "main".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 安装时使用默认路径
|
||||||
|
};
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.install_skill(directory.clone(), repo)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.insert(
|
||||||
|
directory.clone(),
|
||||||
|
SkillState {
|
||||||
|
installed: true,
|
||||||
|
installed_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.uninstall_skill(directory.clone())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
config.skills.skills.remove(&directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_skill_repos(
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<SkillRepo>, String> {
|
||||||
|
let config = app_state.config.read().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(config.skills.repos.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_skill_repo(
|
||||||
|
repo: SkillRepo,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.add_repo(&mut config.skills, repo)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_skill_repo(
|
||||||
|
owner: String,
|
||||||
|
name: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
{
|
||||||
|
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.remove_repo(&mut config.skills, owner, name)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_state.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -31,11 +31,13 @@ pub use mcp::{
|
|||||||
};
|
};
|
||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SpeedtestService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||||
|
SpeedtestService,
|
||||||
};
|
};
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
tray::{TrayIconBuilder, TrayIconEvent},
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
@@ -495,6 +497,17 @@ pub fn run() {
|
|||||||
let _tray = tray_builder.build(app)?;
|
let _tray = tray_builder.build(app)?;
|
||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
|
// 初始化 SkillService
|
||||||
|
match SkillService::new() {
|
||||||
|
Ok(skill_service) => {
|
||||||
|
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("初始化 SkillService 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -573,6 +586,13 @@ pub fn run() {
|
|||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
|
// Skill management
|
||||||
|
commands::get_skills,
|
||||||
|
commands::install_skill,
|
||||||
|
commands::uninstall_skill,
|
||||||
|
commands::get_skill_repos,
|
||||||
|
commands::add_skill_repo,
|
||||||
|
commands::remove_skill_repo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ pub mod config;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
|
pub use skill::{Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
|
|||||||
526
src-tauri/src/services/skill.rs
Normal file
526
src-tauri/src/services/skill.rs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// 技能对象
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Skill {
|
||||||
|
/// 唯一标识: "owner/name:directory" 或 "local:directory"
|
||||||
|
pub key: String,
|
||||||
|
/// 显示名称 (从 SKILL.md 解析)
|
||||||
|
pub name: String,
|
||||||
|
/// 技能描述
|
||||||
|
pub description: String,
|
||||||
|
/// 目录名称 (安装路径的最后一段)
|
||||||
|
pub directory: String,
|
||||||
|
/// GitHub README URL
|
||||||
|
#[serde(rename = "readmeUrl")]
|
||||||
|
pub readme_url: Option<String>,
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 仓库所有者
|
||||||
|
#[serde(rename = "repoOwner")]
|
||||||
|
pub repo_owner: Option<String>,
|
||||||
|
/// 仓库名称
|
||||||
|
#[serde(rename = "repoName")]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
/// 分支名称
|
||||||
|
#[serde(rename = "repoBranch")]
|
||||||
|
pub repo_branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillRepo {
|
||||||
|
/// GitHub 用户/组织名
|
||||||
|
pub owner: String,
|
||||||
|
/// 仓库名称
|
||||||
|
pub name: String,
|
||||||
|
/// 分支 (默认 "main")
|
||||||
|
pub branch: String,
|
||||||
|
/// 是否启用
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||||
|
#[serde(rename = "skillsPath")]
|
||||||
|
pub skills_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能安装状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillState {
|
||||||
|
/// 是否已安装
|
||||||
|
pub installed: bool,
|
||||||
|
/// 安装时间
|
||||||
|
#[serde(rename = "installedAt")]
|
||||||
|
pub installed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化存储结构
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillStore {
|
||||||
|
/// directory -> 安装状态
|
||||||
|
pub skills: HashMap<String, SkillState>,
|
||||||
|
/// 仓库列表
|
||||||
|
pub repos: Vec<SkillRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
SkillStore {
|
||||||
|
skills: HashMap::new(),
|
||||||
|
repos: vec![
|
||||||
|
SkillRepo {
|
||||||
|
owner: "ComposioHQ".to_string(),
|
||||||
|
name: "awesome-claude-skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "anthropics".to_string(),
|
||||||
|
name: "skills".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: None, // 扫描根目录
|
||||||
|
},
|
||||||
|
SkillRepo {
|
||||||
|
owner: "cexll".to_string(),
|
||||||
|
name: "myclaude".to_string(),
|
||||||
|
branch: "master".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 技能元数据 (从 SKILL.md 解析)
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SkillMetadata {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillService {
|
||||||
|
http_client: Client,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillService {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let install_dir = Self::get_install_dir()?;
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
fs::create_dir_all(&install_dir)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
http_client: Client::builder()
|
||||||
|
.user_agent("cc-switch")
|
||||||
|
// 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?,
|
||||||
|
install_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_install_dir() -> Result<PathBuf> {
|
||||||
|
let home = dirs::home_dir().context("无法获取用户主目录")?;
|
||||||
|
Ok(home.join(".claude").join("skills"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心方法实现
|
||||||
|
impl SkillService {
|
||||||
|
/// 列出所有技能
|
||||||
|
pub async fn list_skills(&self, repos: Vec<SkillRepo>) -> Result<Vec<Skill>> {
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新
|
||||||
|
let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|repo| repo.enabled).collect();
|
||||||
|
|
||||||
|
let fetch_tasks = enabled_repos
|
||||||
|
.iter()
|
||||||
|
.map(|repo| self.fetch_repo_skills(repo));
|
||||||
|
|
||||||
|
let results: Vec<Result<Vec<Skill>>> = futures::future::join_all(fetch_tasks).await;
|
||||||
|
|
||||||
|
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||||
|
match result {
|
||||||
|
Ok(repo_skills) => skills.extend(repo_skills),
|
||||||
|
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并本地技能
|
||||||
|
self.merge_local_skills(&mut skills)?;
|
||||||
|
|
||||||
|
// 去重并排序
|
||||||
|
Self::deduplicate_skills(&mut skills);
|
||||||
|
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从仓库获取技能列表
|
||||||
|
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||||
|
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
|
||||||
|
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
|
// 确定要扫描的目录路径
|
||||||
|
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
// 如果指定了 skillsPath,则扫描该子目录
|
||||||
|
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||||
|
if !subdir.exists() {
|
||||||
|
log::warn!(
|
||||||
|
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||||
|
repo.owner,
|
||||||
|
repo.name,
|
||||||
|
skills_path
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Ok(skills);
|
||||||
|
}
|
||||||
|
subdir
|
||||||
|
} else {
|
||||||
|
// 否则扫描仓库根目录
|
||||||
|
temp_dir.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 遍历目标目录
|
||||||
|
for entry in fs::read_dir(&scan_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if !skill_md.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析技能元数据
|
||||||
|
match self.parse_skill_metadata(&skill_md) {
|
||||||
|
Ok(meta) => {
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 构建 README URL(考虑 skillsPath)
|
||||||
|
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||||
|
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||||
|
} else {
|
||||||
|
directory.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory,
|
||||||
|
readme_url: Some(format!(
|
||||||
|
"https://github.com/{}/{}/tree/{}/{}",
|
||||||
|
repo.owner, repo.name, repo.branch, readme_path
|
||||||
|
)),
|
||||||
|
installed: false,
|
||||||
|
repo_owner: Some(repo.owner.clone()),
|
||||||
|
repo_name: Some(repo.name.clone()),
|
||||||
|
repo_branch: Some(repo.branch.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析技能元数据
|
||||||
|
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
// 移除 BOM
|
||||||
|
let content = content.trim_start_matches('\u{feff}');
|
||||||
|
|
||||||
|
// 提取 YAML front matter
|
||||||
|
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return Ok(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let front_matter = parts[1].trim();
|
||||||
|
let meta: SkillMetadata = serde_yaml::from_str(front_matter).unwrap_or(SkillMetadata {
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并本地技能
|
||||||
|
fn merge_local_skills(&self, skills: &mut Vec<Skill>) -> Result<()> {
|
||||||
|
if !self.install_dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.install_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 更新已安装状态
|
||||||
|
let mut found = false;
|
||||||
|
for skill in skills.iter_mut() {
|
||||||
|
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||||
|
skill.installed = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||||
|
if !found {
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if skill_md.exists() {
|
||||||
|
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||||
|
skills.push(Skill {
|
||||||
|
key: format!("local:{directory}"),
|
||||||
|
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||||
|
description: meta.description.unwrap_or_default(),
|
||||||
|
directory: directory.clone(),
|
||||||
|
readme_url: None,
|
||||||
|
installed: true,
|
||||||
|
repo_owner: None,
|
||||||
|
repo_name: None,
|
||||||
|
repo_branch: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去重技能列表
|
||||||
|
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
skills.retain(|skill| {
|
||||||
|
let key = skill.directory.to_lowercase();
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||||
|
e.insert(true);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载仓库
|
||||||
|
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||||
|
let temp_dir = tempfile::tempdir()?;
|
||||||
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
|
let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理
|
||||||
|
|
||||||
|
// 尝试多个分支
|
||||||
|
let branches = if repo.branch.is_empty() {
|
||||||
|
vec!["main", "master"]
|
||||||
|
} else {
|
||||||
|
vec![repo.branch.as_str(), "main", "master"]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
for branch in branches {
|
||||||
|
let url = format!(
|
||||||
|
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||||
|
repo.owner, repo.name, branch
|
||||||
|
);
|
||||||
|
|
||||||
|
match self.download_and_extract(&url, &temp_path).await {
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(temp_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_error = Some(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("所有分支下载失败")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载并解压 ZIP
|
||||||
|
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||||
|
// 下载 ZIP
|
||||||
|
let response = self.http_client.get(url).send().await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor)?;
|
||||||
|
|
||||||
|
// 获取根目录名称 (GitHub 的 zip 会有一个根目录)
|
||||||
|
let root_name = if !archive.is_empty() {
|
||||||
|
let first_file = archive.by_index(0)?;
|
||||||
|
let name = first_file.name();
|
||||||
|
name.split('/').next().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!("空的压缩包"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解压所有文件
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)?;
|
||||||
|
let file_path = file.name();
|
||||||
|
|
||||||
|
// 跳过根目录,直接提取内容
|
||||||
|
let relative_path =
|
||||||
|
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if relative_path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outpath = dest.join(relative_path);
|
||||||
|
|
||||||
|
if file.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath)?;
|
||||||
|
std::io::copy(&mut file, &mut outfile)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||||
|
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||||
|
if dest.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
|
||||||
|
let temp_dir = timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
self.download_repo(&repo),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
|
||||||
|
|
||||||
|
// 复制到安装目录
|
||||||
|
let source = temp_dir.join(&directory);
|
||||||
|
|
||||||
|
if !source.exists() {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow::anyhow!("技能目录不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧版本
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归复制
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归复制目录
|
||||||
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let dest_path = dest.join(entry.file_name());
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
Self::copy_dir_recursive(&path, &dest_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&path, &dest_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||||
|
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||||
|
let dest = self.install_dir.join(&directory);
|
||||||
|
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出仓库
|
||||||
|
pub fn list_repos(&self, store: &SkillStore) -> Vec<SkillRepo> {
|
||||||
|
store.repos.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加仓库
|
||||||
|
pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> {
|
||||||
|
// 检查重复
|
||||||
|
if let Some(pos) = store
|
||||||
|
.repos
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.owner == repo.owner && r.name == repo.name)
|
||||||
|
{
|
||||||
|
store.repos[pos] = repo;
|
||||||
|
} else {
|
||||||
|
store.repos.push(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除仓库
|
||||||
|
pub fn remove_repo(&self, store: &mut SkillStore, owner: String, name: String) -> Result<()> {
|
||||||
|
store
|
||||||
|
.repos
|
||||||
|
.retain(|r| !(r.owner == owner && r.name == name));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "",
|
"title": "",
|
||||||
"width": 900,
|
"width": 1000,
|
||||||
"height": 650,
|
"height": 650,
|
||||||
"minWidth": 800,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
|
|||||||
27
src/App.tsx
27
src/App.tsx
@@ -22,7 +22,15 @@ import { UpdateBadge } from "@/components/UpdateBadge";
|
|||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -33,6 +41,7 @@ function App() {
|
|||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||||
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||||
|
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
|
||||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||||
@@ -218,6 +227,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
MCP
|
MCP
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => setIsSkillsOpen(true)}
|
||||||
|
className="min-w-[80px]"
|
||||||
|
>
|
||||||
|
{t("skills.manage")}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setIsAddOpen(true)}>
|
<Button onClick={() => setIsAddOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t("header.addProvider")}
|
{t("header.addProvider")}
|
||||||
@@ -303,6 +319,17 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||||
|
|
||||||
|
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>{t("skills.title")}</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
</DialogHeader>
|
||||||
|
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
218
src/components/skills/RepoManager.tsx
Normal file
218
src/components/skills/RepoManager.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import type { Skill, SkillRepo } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface RepoManagerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
repos: SkillRepo[];
|
||||||
|
skills: Skill[];
|
||||||
|
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||||
|
onRemove: (owner: string, name: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepoManager({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
repos,
|
||||||
|
skills,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: RepoManagerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
|
const [skillsPath, setSkillsPath] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const getSkillCount = (repo: SkillRepo) =>
|
||||||
|
skills.filter(
|
||||||
|
(skill) =>
|
||||||
|
skill.repoOwner === repo.owner &&
|
||||||
|
skill.repoName === repo.name &&
|
||||||
|
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const parseRepoUrl = (
|
||||||
|
url: string,
|
||||||
|
): { owner: string; name: string } | null => {
|
||||||
|
// 支持格式:
|
||||||
|
// - https://github.com/owner/name
|
||||||
|
// - owner/name
|
||||||
|
// - https://github.com/owner/name.git
|
||||||
|
|
||||||
|
let cleaned = url.trim();
|
||||||
|
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||||
|
cleaned = cleaned.replace(/\.git$/, "");
|
||||||
|
|
||||||
|
const parts = cleaned.split("/");
|
||||||
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||||
|
return { owner: parts[0], name: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const parsed = parseRepoUrl(repoUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
setError(t("skills.repo.invalidUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAdd({
|
||||||
|
owner: parsed.owner,
|
||||||
|
name: parsed.name,
|
||||||
|
branch: branch || "main",
|
||||||
|
enabled: true,
|
||||||
|
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
|
||||||
|
});
|
||||||
|
|
||||||
|
setRepoUrl("");
|
||||||
|
setBranch("");
|
||||||
|
setSkillsPath("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenRepo = async (owner: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open URL:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||||
|
{/* 固定头部 */}
|
||||||
|
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
|
||||||
|
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 可滚动内容区域 */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||||
|
{/* 添加仓库表单 */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
id="repo-url"
|
||||||
|
placeholder={t("skills.repo.urlPlaceholder")}
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => setRepoUrl(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="branch"
|
||||||
|
placeholder={t("skills.repo.branchPlaceholder")}
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="skills-path"
|
||||||
|
placeholder={t("skills.repo.pathPlaceholder")}
|
||||||
|
value={skillsPath}
|
||||||
|
onChange={(e) => setSkillsPath(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="w-full sm:w-auto sm:px-4"
|
||||||
|
variant="mcp"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t("skills.repo.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库列表 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
|
||||||
|
{repos.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("skills.repo.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<div
|
||||||
|
key={`${repo.owner}/${repo.name}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{repo.owner}/{repo.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||||
|
{repo.skillsPath && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
{t("skills.repo.path")}: {repo.skillsPath}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||||
|
{t("skills.repo.skillCount", {
|
||||||
|
count: getSkillCount(repo),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||||
|
title={t("common.view", { defaultValue: "查看" })}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(repo.owner, repo.name)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/skills/SkillCard.tsx
Normal file
145
src/components/skills/SkillCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import type { Skill } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface SkillCardProps {
|
||||||
|
skill: Skill;
|
||||||
|
onInstall: (directory: string) => Promise<void>;
|
||||||
|
onUninstall: (directory: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onInstall(skill.directory);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onUninstall(skill.directory);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGithub = async () => {
|
||||||
|
if (skill.readmeUrl) {
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(skill.readmeUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to open URL:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDirectory =
|
||||||
|
Boolean(skill.directory) &&
|
||||||
|
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-base font-semibold truncate">
|
||||||
|
{skill.name}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
{showDirectory && (
|
||||||
|
<CardDescription className="text-xs truncate">
|
||||||
|
{skill.directory}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
{skill.repoOwner && skill.repoName && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 text-[10px] px-1.5 py-0 h-4 border-border-default"
|
||||||
|
>
|
||||||
|
{skill.repoOwner}/{skill.repoName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{skill.installed && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
|
||||||
|
>
|
||||||
|
{t("skills.installed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed">
|
||||||
|
{skill.description || t("skills.noDescription")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
|
||||||
|
{skill.readmeUrl && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpenGithub}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{t("skills.view")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{skill.installed ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUninstall}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-900/50 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{loading ? t("skills.uninstalling") : t("skills.uninstall")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={loading || !skill.repoOwner}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{loading ? t("skills.installing") : t("skills.install")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/skills/SkillsPage.tsx
Normal file
190
src/components/skills/SkillsPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RefreshCw, Settings } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SkillCard } from "./SkillCard";
|
||||||
|
import { RepoManager } from "./RepoManager";
|
||||||
|
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||||
|
|
||||||
|
interface SkillsPageProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await skillsApi.getAll();
|
||||||
|
setSkills(data);
|
||||||
|
if (afterLoad) {
|
||||||
|
afterLoad(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.loadFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRepos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await skillsApi.getRepos();
|
||||||
|
setRepos(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load repos:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadSkills(), loadRepos()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
await skillsApi.install(directory);
|
||||||
|
toast.success(t("skills.installSuccess", { name: directory }));
|
||||||
|
await loadSkills();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.installFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
await skillsApi.uninstall(directory);
|
||||||
|
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||||
|
await loadSkills();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("skills.uninstallFailed"), {
|
||||||
|
description: error instanceof Error ? error.message : t("common.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepo = async (repo: SkillRepo) => {
|
||||||
|
await skillsApi.addRepo(repo);
|
||||||
|
|
||||||
|
let repoSkillCount = 0;
|
||||||
|
await Promise.all([
|
||||||
|
loadRepos(),
|
||||||
|
loadSkills((data) => {
|
||||||
|
repoSkillCount = data.filter(
|
||||||
|
(skill) =>
|
||||||
|
skill.repoOwner === repo.owner &&
|
||||||
|
skill.repoName === repo.name &&
|
||||||
|
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||||
|
).length;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("skills.repo.addSuccess", {
|
||||||
|
owner: repo.owner,
|
||||||
|
name: repo.name,
|
||||||
|
count: repoSkillCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||||
|
await skillsApi.removeRepo(owner, name);
|
||||||
|
toast.success(t("skills.repo.removeSuccess", { owner, name }));
|
||||||
|
await Promise.all([loadRepos(), loadSkills()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 bg-background">
|
||||||
|
{/* 顶部操作栏(固定区域) */}
|
||||||
|
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
|
||||||
|
{t("skills.title")}
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadSkills()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{loading ? t("skills.refreshing") : t("skills.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="mcp"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRepoManagerOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
{t("skills.repoManager")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("skills.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 技能网格(可滚动详情区域) */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : skills.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{t("skills.empty")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("skills.emptyDescription")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setRepoManagerOpen(true)}
|
||||||
|
className="mt-3 text-sm font-normal"
|
||||||
|
>
|
||||||
|
{t("skills.addRepo")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.key}
|
||||||
|
skill={skill}
|
||||||
|
onInstall={handleInstall}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仓库管理对话框 */}
|
||||||
|
<RepoManager
|
||||||
|
open={repoManagerOpen}
|
||||||
|
onOpenChange={setRepoManagerOpen}
|
||||||
|
repos={repos}
|
||||||
|
skills={skills}
|
||||||
|
onAdd={handleAddRepo}
|
||||||
|
onRemove={handleRemoveRepo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
86
src/components/ui/card.tsx
Normal file
86
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
121
src/components/ui/table.tsx
Normal file
121
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr]:border-b [&_tr]:border-border-default", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border-default transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"format": "Format",
|
"format": "Format",
|
||||||
"formatSuccess": "Formatted successfully",
|
"formatSuccess": "Formatted successfully",
|
||||||
"formatError": "Format failed: {{error}}",
|
"formatError": "Format failed: {{error}}",
|
||||||
"copy": "Copy"
|
"copy": "Copy",
|
||||||
|
"view": "View"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "Enter API Key",
|
"placeholder": "Enter API Key",
|
||||||
@@ -606,5 +607,49 @@
|
|||||||
"deleteTitle": "Confirm Delete",
|
"deleteTitle": "Confirm Delete",
|
||||||
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
|
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"manage": "Skills",
|
||||||
|
"title": "Claude Skills Management",
|
||||||
|
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"repoManager": "Repository Management",
|
||||||
|
"count": "{{count}} skills",
|
||||||
|
"empty": "No skills available",
|
||||||
|
"emptyDescription": "Add skill repositories to discover available skills",
|
||||||
|
"addRepo": "Add Skill Repository",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"installed": "Installed",
|
||||||
|
"install": "Install",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"uninstalling": "Uninstalling...",
|
||||||
|
"view": "View",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"loadFailed": "Failed to load",
|
||||||
|
"installSuccess": "Skill {{name}} installed",
|
||||||
|
"installFailed": "Failed to install",
|
||||||
|
"uninstallSuccess": "Skill {{name}} uninstalled",
|
||||||
|
"uninstallFailed": "Failed to uninstall",
|
||||||
|
"repo": {
|
||||||
|
"title": "Manage Skill Repositories",
|
||||||
|
"description": "Add or remove GitHub skill repository sources",
|
||||||
|
"url": "Repository URL",
|
||||||
|
"urlPlaceholder": "owner/name or https://github.com/owner/name",
|
||||||
|
"branch": "Branch",
|
||||||
|
"branchPlaceholder": "main",
|
||||||
|
"path": "Skills Path",
|
||||||
|
"pathPlaceholder": "skills (optional, leave empty for root)",
|
||||||
|
"add": "Add Repository",
|
||||||
|
"list": "Added Repositories",
|
||||||
|
"empty": "No repositories",
|
||||||
|
"invalidUrl": "Invalid repository URL format",
|
||||||
|
"addSuccess": "Repository {{owner}}/{{name}} added, detected {{count}} skills",
|
||||||
|
"addFailed": "Failed to add",
|
||||||
|
"removeSuccess": "Repository {{owner}}/{{name}} removed",
|
||||||
|
"removeFailed": "Failed to remove",
|
||||||
|
"skillCount": "{{count}} skills detected"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"format": "格式化",
|
"format": "格式化",
|
||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatError": "格式化失败:{{error}}",
|
"formatError": "格式化失败:{{error}}",
|
||||||
"copy": "复制"
|
"copy": "复制",
|
||||||
|
"view": "查看"
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "请输入API Key",
|
"placeholder": "请输入API Key",
|
||||||
@@ -606,5 +607,49 @@
|
|||||||
"deleteTitle": "确认删除",
|
"deleteTitle": "确认删除",
|
||||||
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
|
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"manage": "Skills",
|
||||||
|
"title": "Claude Skills 管理",
|
||||||
|
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"refreshing": "刷新中...",
|
||||||
|
"repoManager": "仓库管理",
|
||||||
|
"count": "共 {{count}} 个技能",
|
||||||
|
"empty": "暂无可用技能",
|
||||||
|
"emptyDescription": "添加技能仓库以发现可用的技能",
|
||||||
|
"addRepo": "添加技能仓库",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"installed": "已安装",
|
||||||
|
"install": "安装",
|
||||||
|
"installing": "安装中...",
|
||||||
|
"uninstall": "卸载",
|
||||||
|
"uninstalling": "卸载中...",
|
||||||
|
"view": "查看",
|
||||||
|
"noDescription": "暂无描述",
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"installSuccess": "技能 {{name}} 已安装",
|
||||||
|
"installFailed": "安装失败",
|
||||||
|
"uninstallSuccess": "技能 {{name}} 已卸载",
|
||||||
|
"uninstallFailed": "卸载失败",
|
||||||
|
"repo": {
|
||||||
|
"title": "管理技能仓库",
|
||||||
|
"description": "添加或删除 GitHub 技能仓库源",
|
||||||
|
"url": "仓库 URL",
|
||||||
|
"urlPlaceholder": "owner/name 或 https://github.com/owner/name",
|
||||||
|
"branch": "分支",
|
||||||
|
"branchPlaceholder": "main",
|
||||||
|
"path": "技能路径",
|
||||||
|
"pathPlaceholder": "skills (可选,留空扫描根目录)",
|
||||||
|
"add": "添加仓库",
|
||||||
|
"list": "已添加的仓库",
|
||||||
|
"empty": "暂无仓库",
|
||||||
|
"invalidUrl": "无效的仓库 URL 格式",
|
||||||
|
"addSuccess": "仓库 {{owner}}/{{name}} 已添加,识别到 {{count}} 个技能",
|
||||||
|
"addFailed": "添加失败",
|
||||||
|
"removeSuccess": "仓库 {{owner}}/{{name}} 已删除",
|
||||||
|
"removeFailed": "删除失败",
|
||||||
|
"skillCount": "识别到 {{count}} 个技能"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/lib/api/skills.ts
Normal file
47
src/lib/api/skills.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skillsApi = {
|
||||||
|
async getAll(): Promise<Skill[]> {
|
||||||
|
return await invoke("get_skills");
|
||||||
|
},
|
||||||
|
|
||||||
|
async install(directory: string): Promise<boolean> {
|
||||||
|
return await invoke("install_skill", { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(directory: string): Promise<boolean> {
|
||||||
|
return await invoke("uninstall_skill", { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRepos(): Promise<SkillRepo[]> {
|
||||||
|
return await invoke("get_skill_repos");
|
||||||
|
},
|
||||||
|
|
||||||
|
async addRepo(repo: SkillRepo): Promise<boolean> {
|
||||||
|
return await invoke("add_skill_repo", { repo });
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeRepo(owner: string, name: string): Promise<boolean> {
|
||||||
|
return await invoke("remove_skill_repo", { owner, name });
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user