diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 8c63ec589..5bec2b17b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -9,6 +9,7 @@ use crate::error::format_skill_error; use crate::services::skill::{ DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, + SkillsShSearchResult, }; use crate::store::AppState; use std::sync::Arc; @@ -170,6 +171,18 @@ pub async fn migrate_skill_storage( SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) } +/// 搜索 skills.sh 公共目录 +#[tauri::command] +pub async fn search_skills_sh( + query: String, + limit: usize, + offset: usize, +) -> Result { + SkillService::search_skills_sh(&query, limit, offset) + .await + .map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e60c3ae23..d7490e924 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -966,6 +966,7 @@ pub fn run() { commands::check_skill_updates, commands::update_skill, commands::migrate_skill_storage, + commands::search_skills_sh, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index adadf54ad..2ce39ea02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -194,6 +194,58 @@ pub struct MigrationResult { pub errors: Vec, } +// ========== skills.sh API 类型 ========== + +/// skills.sh API 原始响应 +/// +/// 注意:API 命名不一致(searchType 是 camelCase,duration_ms 是 snake_case), +/// 因此不能用 rename_all,需要逐字段指定。 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiResponse { + pub query: String, + #[serde(rename = "searchType")] + #[allow(dead_code)] + pub search_type: String, + pub skills: Vec, + pub count: usize, + #[allow(dead_code)] + pub duration_ms: u64, +} + +/// skills.sh API 原始技能条目 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiSkill { + pub id: String, + #[serde(rename = "skillId")] + pub skill_id: String, + pub name: String, + pub installs: u64, + pub source: String, +} + +/// skills.sh 搜索结果(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShSearchResult { + pub skills: Vec, + pub total_count: usize, + pub query: String, +} + +/// skills.sh 可安装技能(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShDiscoverableSkill { + pub key: String, + pub name: String, + pub directory: String, + pub repo_owner: String, + pub repo_name: String, + pub repo_branch: String, + pub installs: u64, + pub readme_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -614,14 +666,28 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let source = temp_dir.join(&source_rel); + let mut source = temp_dir.join(&source_rel); if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); + // 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md) + let target_name = source_rel + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) { + log::info!( + "Skill directory '{}' not found at direct path, using fallback: {}", + target_name, + found.display() + ); + source = found; + } else { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); + } } let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); @@ -1982,6 +2048,38 @@ impl SkillService { } } + /// 在目录树中查找名称匹配且包含 SKILL.md 的子目录 + /// + /// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"), + /// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。 + fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option { + fn walk(dir: &Path, target: &str, depth: usize) -> Option { + if depth > 3 { + return None; + } + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() { + return Some(path); + } + if let Some(found) = walk(&path, target, depth + 1) { + return Some(found); + } + } + None + } + walk(root, target_name, 0) + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -2589,6 +2687,70 @@ impl SkillService { Ok(()) } + + // ========== skills.sh 搜索 ========== + + /// 搜索 skills.sh 公共目录 + pub async fn search_skills_sh( + query: &str, + limit: usize, + offset: usize, + ) -> Result { + let client = crate::proxy::http_client::get(); + + let url = url::Url::parse_with_params( + "https://skills.sh/api/search", + &[ + ("q", query), + ("limit", &limit.to_string()), + ("offset", &offset.to_string()), + ], + )?; + + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let skills = resp + .skills + .into_iter() + .filter_map(|s| { + let parts: Vec<&str> = s.source.splitn(2, '/').collect(); + if parts.len() != 2 { + return None; + } + let (owner, repo) = (parts[0].to_string(), parts[1].to_string()); + // 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works") + if owner.contains('.') || repo.contains('.') { + return None; + } + Some(SkillsShDiscoverableSkill { + key: s.id, + name: s.name, + directory: s.skill_id.clone(), + repo_owner: owner.clone(), + repo_name: repo.clone(), + repo_branch: "main".to_string(), + installs: s.installs, + readme_url: Some(format!( + "https://github.com/{}/{}/tree/main/{}", + owner, repo, s.skill_id + )), + }) + }) + .collect(); + + Ok(SkillsShSearchResult { + skills, + total_count: resp.count, + query: resp.query, + }) + } } // ========== 迁移支持 ========== diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 5787f72ec..6c0d20b87 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -20,9 +20,15 @@ interface SkillCardProps { skill: SkillCardSkill; onInstall: (directory: string) => Promise; onUninstall: (directory: string) => Promise; + installs?: number; } -export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { +export function SkillCard({ + skill, + onInstall, + onUninstall, + installs, +}: SkillCardProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -81,6 +87,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { {skill.repoOwner}/{skill.repoName} )} + {typeof installs === "number" && ( + + + {installs.toLocaleString()} + + )} {skill.installed && ( diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index ba09c835b..c24d68e94 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,4 +1,10 @@ -import { useState, useMemo, forwardRef, useImperativeHandle } from "react"; +import { + useState, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RefreshCw, Search } from "lucide-react"; +import { RefreshCw, Search, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; @@ -20,9 +26,14 @@ import { useSkillRepos, useAddSkillRepo, useRemoveSkillRepo, + useSearchSkillsSh, } from "@/hooks/useSkills"; import type { AppId } from "@/lib/api/types"; -import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; +import type { + DiscoverableSkill, + SkillRepo, + SkillsShDiscoverableSkill, +} from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { @@ -34,9 +45,13 @@ export interface SkillsPageHandle { openRepoManager: () => void; } +type SearchSource = "repos" | "skillssh"; + +const SKILLSSH_PAGE_SIZE = 20; + /** * Skills 发现面板 - * 用于浏览和安装来自仓库的 Skills + * 用于浏览和安装来自仓库或 skills.sh 的 Skills */ export const SkillsPage = forwardRef( ({ initialApp = "claude" }, ref) => { @@ -48,6 +63,15 @@ export const SkillsPage = forwardRef( "all" | "installed" | "uninstalled" >("all"); + // skills.sh 搜索状态 + const [searchSource, setSearchSource] = useState("repos"); + const [skillsShInput, setSkillsShInput] = useState(""); + const [skillsShQuery, setSkillsShQuery] = useState(""); + const [skillsShOffset, setSkillsShOffset] = useState(0); + const [accumulatedResults, setAccumulatedResults] = useState< + SkillsShDiscoverableSkill[] + >([]); + // currentApp 用于安装时的默认应用 const currentApp = initialApp; @@ -61,6 +85,33 @@ export const SkillsPage = forwardRef( const { data: installedSkills } = useInstalledSkills(); const { data: repos = [], refetch: refetchRepos } = useSkillRepos(); + // skills.sh 搜索 + const { + data: skillsShResult, + isLoading: loadingSkillsSh, + isFetching: fetchingSkillsSh, + } = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset); + + // 当搜索结果返回时累积 + useEffect(() => { + if (skillsShResult) { + if (skillsShOffset === 0) { + setAccumulatedResults(skillsShResult.skills); + } else { + setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]); + } + } + }, [skillsShResult, skillsShOffset]); + + // 手动提交搜索 + const handleSkillsShSearch = () => { + const trimmed = skillsShInput.trim(); + if (trimmed.length < 2) return; + setSkillsShOffset(0); + setAccumulatedResults([]); + setSkillsShQuery(trimmed); + }; + // Mutations const installMutation = useInstallSkill(); const addRepoMutation = useAddSkillRepo(); @@ -110,7 +161,16 @@ export const SkillsPage = forwardRef( }); }, [discoverableSkills, installedKeys]); - const loading = loadingDiscoverable || fetchingDiscoverable; + // 检查 skills.sh 结果的安装状态 + const isSkillsShInstalled = (skill: SkillsShDiscoverableSkill): boolean => { + const key = `${skill.directory.toLowerCase()}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`; + return installedKeys.has(key); + }; + + const loading = + searchSource === "repos" + ? loadingDiscoverable || fetchingDiscoverable + : false; useImperativeHandle(ref, () => ({ refresh: () => { @@ -120,13 +180,36 @@ export const SkillsPage = forwardRef( openRepoManager: () => setRepoManagerOpen(true), })); + // skills.sh 结果转为 DiscoverableSkill(复用现有安装流程) + const toDiscoverableSkill = ( + s: SkillsShDiscoverableSkill, + ): DiscoverableSkill => ({ + key: s.key, + name: s.name, + description: "", + directory: s.directory, + repoOwner: s.repoOwner, + repoName: s.repoName, + repoBranch: s.repoBranch, + readmeUrl: s.readmeUrl, + }); + const handleInstall = async (directory: string) => { - // 找到对应的 DiscoverableSkill - const skill = discoverableSkills?.find( - (s) => - s.directory === directory || - s.directory.split("/").pop() === directory, - ); + let skill: DiscoverableSkill | undefined; + + if (searchSource === "skillssh") { + const found = accumulatedResults.find((s) => s.directory === directory); + if (found) { + skill = toDiscoverableSkill(found); + } + } else { + skill = discoverableSkills?.find( + (s) => + s.directory === directory || + s.directory.split("/").pop() === directory, + ); + } + if (!skill) { toast.error(t("skills.notFound")); return; @@ -201,7 +284,7 @@ export const SkillsPage = forwardRef( } }; - // 过滤技能列表 + // 过滤技能列表(仓库模式) const filteredSkills = useMemo(() => { // 按仓库筛选 const byRepo = skills.filter((skill) => { @@ -232,35 +315,56 @@ export const SkillsPage = forwardRef( }); }, [skills, searchQuery, filterRepo, filterStatus]); + // 是否有更多 skills.sh 结果 + const hasMoreSkillsSh = + skillsShResult && accumulatedResults.length < skillsShResult.totalCount; + + // 无仓库时默认切换到 skills.sh + const effectiveSource = + searchSource === "repos" && skills.length === 0 && !loading + ? "skillssh" + : searchSource; + return (
{/* 技能网格(可滚动详情区域) */}
- {loading ? ( -
- -
- ) : skills.length === 0 ? ( -
-

- {t("skills.empty")} -

-

- {t("skills.emptyDescription")} -

+ {/* 搜索来源切换 + 搜索框 */} +
+ {/* 来源切换 */} +
+
- ) : ( - <> - {/* 搜索框和筛选器 */} -
+ + {effectiveSource === "repos" ? ( + <> + {/* 仓库模式搜索框 */}
( {t("skills.count", { count: filteredSkills.length })}

)} -
+ + ) : ( + <> + {/* skills.sh 搜索框 */} +
+ + setSkillsShInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSkillsShSearch(); + }} + className="pl-9 pr-3" + /> +
+ + + )} +
- {/* 技能列表或无结果提示 */} - {filteredSkills.length === 0 ? ( + {/* 内容区域 */} + {effectiveSource === "repos" ? ( + /* ===== 仓库模式 ===== */ + loading ? ( +
+ +
+ ) : skills.length === 0 ? ( +
+

+ {t("skills.empty")} +

+

+ {t("skills.emptyDescription")} +

+ +
+ ) : filteredSkills.length === 0 ? ( +
+

+ {t("skills.noResults")} +

+

+ {t("skills.emptyDescription")} +

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ ) + ) : ( + /* ===== skills.sh 模式 ===== */ + <> + {loadingSkillsSh && accumulatedResults.length === 0 ? ( +
+ + + {t("skills.skillssh.loading")} + +
+ ) : skillsShQuery.length < 2 ? ( +
+ +

+ {t("skills.skillssh.searchPlaceholder")} +

+
+ ) : accumulatedResults.length === 0 && !loadingSkillsSh ? (

- {t("skills.noResults")} -

-

- {t("skills.emptyDescription")} + {t("skills.skillssh.noResults", { + query: skillsShQuery, + })}

) : ( -
- {filteredSkills.map((skill) => ( - - ))} -
+ <> +
+ {accumulatedResults.map((skill) => { + const installed = isSkillsShInstalled(skill); + return ( + + ); + })} +
+ + {/* 加载更多 + 底部信息 */} +
+ {hasMoreSkillsSh && ( + + )} +

+ {t("skills.skillssh.poweredBy")} +

+
+ )} )} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..4e9f9a04b --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +/** + * 返回一个延迟更新的值,在指定时间内无新变化后才更新。 + * 用于搜索输入等场景,避免每次按键都触发请求。 + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 65d67aa63..ea641f099 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -11,6 +11,7 @@ import { type ImportSkillSelection, type InstalledSkill, type SkillUpdateInfo, + type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; @@ -326,6 +327,26 @@ export function useUpdateSkill() { }); } +// ========== skills.sh 搜索 ========== + +/** + * 搜索 skills.sh 公共目录 + * 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验 + */ +export function useSearchSkillsSh( + query: string, + limit: number, + offset: number, +) { + return useQuery({ + queryKey: ["skills", "skillssh", query, limit, offset], + queryFn: () => skillsApi.searchSkillsSh(query, limit, offset), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, + }); +} + // ========== 辅助类型 ========== export type { @@ -334,5 +355,6 @@ export type { ImportSkillSelection, SkillBackupEntry, SkillUpdateInfo, + SkillsShSearchResult, AppId, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b8440e7c..da49b30a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1650,6 +1650,19 @@ }, "search": "Search Skills", "searchPlaceholder": "Search skill name or repo...", + "searchSource": { + "repos": "Repos", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "Search skills.sh (min 2 chars)...", + "installs": "{{count}} installs", + "loadMore": "Load More", + "loading": "Searching skills.sh...", + "noResults": "No skills found for \"{{query}}\"", + "error": "Failed to search skills.sh", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "Filter by status", "all": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f2ccfac41..bc13a3138 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,6 +1650,19 @@ }, "search": "スキルを検索", "searchPlaceholder": "スキル名またはリポジトリで検索...", + "searchSource": { + "repos": "リポジトリ", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "skills.sh を検索(2文字以上)...", + "installs": "{{count}} インストール", + "loadMore": "さらに読み込む", + "loading": "skills.sh を検索中...", + "noResults": "\"{{query}}\" に該当するスキルがありません", + "error": "skills.sh の検索に失敗しました", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "状態で絞り込み", "all": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6427611ea..431e8354f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1650,6 +1650,19 @@ }, "search": "搜索技能", "searchPlaceholder": "搜索技能名称或仓库名称...", + "searchSource": { + "repos": "仓库", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "搜索 skills.sh(至少 2 个字符)...", + "installs": "{{count}} 次安装", + "loadMore": "加载更多", + "loading": "正在搜索 skills.sh...", + "noResults": "未找到 \"{{query}}\" 相关技能", + "error": "搜索 skills.sh 失败", + "poweredBy": "由 skills.sh 提供" + }, "filter": { "placeholder": "状态筛选", "all": "全部", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3e92e88b0..456e57b67 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -95,6 +95,25 @@ export interface MigrationResult { errors: string[]; } +/** skills.sh 可发现的技能 */ +export interface SkillsShDiscoverableSkill { + key: string; + name: string; + directory: string; + repoOwner: string; + repoName: string; + repoBranch: string; + installs: number; + readmeUrl?: string; +} + +/** skills.sh 搜索结果 */ +export interface SkillsShSearchResult { + skills: SkillsShDiscoverableSkill[]; + totalCount: number; + query: string; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -183,6 +202,15 @@ export const skillsApi = { return await invoke("migrate_skill_storage", { target }); }, + /** 搜索 skills.sh 公共目录 */ + async searchSkillsSh( + query: string, + limit: number, + offset: number, + ): Promise { + return await invoke("search_skills_sh", { query, limit, offset }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */