mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -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<SkillsShSearchResult, String> {
|
||||
SkillService::search_skills_sh(&query, limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 兼容旧 API 的命令 ==========
|
||||
|
||||
/// 获取技能列表(兼容旧 API)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -194,6 +194,58 @@ pub struct MigrationResult {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
// ========== 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<SkillsShApiSkill>,
|
||||
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<SkillsShDiscoverableSkill>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<PathBuf> {
|
||||
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
|
||||
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<DiscoverableSkill>) {
|
||||
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<SkillsShSearchResult> {
|
||||
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::<SkillsShApiResponse>()
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 迁移支持 ==========
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* 返回一个延迟更新的值,在指定时间内无新变化后才更新。
|
||||
* 用于搜索输入等场景,避免每次按键都触发请求。
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "すべて",
|
||||
|
||||
@@ -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": "全部",
|
||||
|
||||
@@ -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<SkillsShSearchResult> {
|
||||
return await invoke("search_skills_sh", { query, limit, offset });
|
||||
},
|
||||
|
||||
// ========== 兼容旧 API ==========
|
||||
|
||||
/** 获取技能列表(兼容旧 API) */
|
||||
|
||||
Reference in New Issue
Block a user