mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
feat: integrate skills.sh search for discovering skills from public registry
Add skills.sh API integration allowing users to search and install from a catalog of 91K+ agent skills directly within CC Switch. The search results are converted to DiscoverableSkill objects and reuse the existing install pipeline. Includes fallback directory search for repos where skills are nested in subdirectories, and filters out non-GitHub sources.
This commit is contained in:
@@ -20,9 +20,15 @@ interface SkillCardProps {
|
||||
skill: SkillCardSkill;
|
||||
onInstall: (directory: string) => Promise<void>;
|
||||
onUninstall: (directory: string) => Promise<void>;
|
||||
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}
|
||||
</Badge>
|
||||
)}
|
||||
{typeof installs === "number" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
<Download className="h-2.5 w-2.5 mr-0.5" />
|
||||
{installs.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{skill.installed && (
|
||||
|
||||
@@ -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<SkillsPageHandle, SkillsPageProps>(
|
||||
({ initialApp = "claude" }, ref) => {
|
||||
@@ -48,6 +63,15 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
"all" | "installed" | "uninstalled"
|
||||
>("all");
|
||||
|
||||
// skills.sh 搜索状态
|
||||
const [searchSource, setSearchSource] = useState<SearchSource>("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<SkillsPageHandle, SkillsPageProps>(
|
||||
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<SkillsPageHandle, SkillsPageProps>(
|
||||
});
|
||||
}, [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<SkillsPageHandle, SkillsPageProps>(
|
||||
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<SkillsPageHandle, SkillsPageProps>(
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤技能列表
|
||||
// 过滤技能列表(仓库模式)
|
||||
const filteredSkills = useMemo(() => {
|
||||
// 按仓库筛选
|
||||
const byRepo = skills.filter((skill) => {
|
||||
@@ -232,35 +315,56 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
});
|
||||
}, [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 (
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
|
||||
<div className="py-4">
|
||||
{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-foreground">
|
||||
{t("skills.empty")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("skills.emptyDescription")}
|
||||
</p>
|
||||
{/* 搜索来源切换 + 搜索框 */}
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
{/* 来源切换 */}
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1 shrink-0">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
className="mt-3 text-sm font-normal"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={effectiveSource === "repos" ? "default" : "ghost"}
|
||||
className={
|
||||
effectiveSource === "repos"
|
||||
? "shadow-sm min-w-[64px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[64px]"
|
||||
}
|
||||
onClick={() => setSearchSource("repos")}
|
||||
>
|
||||
{t("skills.addRepo")}
|
||||
{t("skills.searchSource.repos")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={effectiveSource === "skillssh" ? "default" : "ghost"}
|
||||
className={
|
||||
effectiveSource === "skillssh"
|
||||
? "shadow-sm min-w-[80px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[80px]"
|
||||
}
|
||||
onClick={() => setSearchSource("skillssh")}
|
||||
>
|
||||
skills.sh
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 搜索框和筛选器 */}
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
|
||||
{effectiveSource === "repos" ? (
|
||||
<>
|
||||
{/* 仓库模式搜索框 */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -345,29 +449,155 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
{t("skills.count", { count: filteredSkills.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* skills.sh 搜索框 */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("skills.skillssh.searchPlaceholder")}
|
||||
value={skillsShInput}
|
||||
onChange={(e) => setSkillsShInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSkillsShSearch();
|
||||
}}
|
||||
className="pl-9 pr-3"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSkillsShSearch}
|
||||
disabled={
|
||||
skillsShInput.trim().length < 2 || fetchingSkillsSh
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{fetchingSkillsSh ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t("skills.search")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 技能列表或无结果提示 */}
|
||||
{filteredSkills.length === 0 ? (
|
||||
{/* 内容区域 */}
|
||||
{effectiveSource === "repos" ? (
|
||||
/* ===== 仓库模式 ===== */
|
||||
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-foreground">
|
||||
{t("skills.empty")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("skills.emptyDescription")}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setRepoManagerOpen(true)}
|
||||
className="mt-3 text-sm font-normal"
|
||||
>
|
||||
{t("skills.addRepo")}
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t("skills.noResults")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("skills.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* ===== skills.sh 模式 ===== */
|
||||
<>
|
||||
{loadingSkillsSh && accumulatedResults.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
{t("skills.skillssh.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : skillsShQuery.length < 2 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("skills.skillssh.searchPlaceholder")}
|
||||
</p>
|
||||
</div>
|
||||
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t("skills.noResults")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("skills.emptyDescription")}
|
||||
{t("skills.skillssh.noResults", {
|
||||
query: skillsShQuery,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{accumulatedResults.map((skill) => {
|
||||
const installed = isSkillsShInstalled(skill);
|
||||
return (
|
||||
<SkillCard
|
||||
key={skill.key}
|
||||
skill={{
|
||||
...toDiscoverableSkill(skill),
|
||||
installed,
|
||||
}}
|
||||
installs={skill.installs}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 加载更多 + 底部信息 */}
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
{hasMoreSkillsSh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={fetchingSkillsSh}
|
||||
onClick={() =>
|
||||
setSkillsShOffset(
|
||||
(prev) => prev + SKILLSSH_PAGE_SIZE,
|
||||
)
|
||||
}
|
||||
>
|
||||
{fetchingSkillsSh ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : null}
|
||||
{t("skills.skillssh.loadMore")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("skills.skillssh.poweredBy")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user