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:
27
src/App.tsx
27
src/App.tsx
@@ -22,7 +22,15 @@ import { UpdateBadge } from "@/components/UpdateBadge";
|
||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +41,7 @@ function App() {
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isMcpOpen, setIsMcpOpen] = useState(false);
|
||||
const [isPromptOpen, setIsPromptOpen] = useState(false);
|
||||
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
|
||||
@@ -218,6 +227,13 @@ function App() {
|
||||
>
|
||||
MCP
|
||||
</Button>
|
||||
<Button
|
||||
variant="mcp"
|
||||
onClick={() => setIsSkillsOpen(true)}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t("skills.manage")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("header.addProvider")}
|
||||
@@ -303,6 +319,17 @@ function App() {
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
"formatSuccess": "Formatted successfully",
|
||||
"formatError": "Format failed: {{error}}",
|
||||
"copy": "Copy"
|
||||
"copy": "Copy",
|
||||
"view": "View"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -606,5 +607,49 @@
|
||||
"deleteTitle": "Confirm Delete",
|
||||
"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": "格式化",
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatError": "格式化失败:{{error}}",
|
||||
"copy": "复制"
|
||||
"copy": "复制",
|
||||
"view": "查看"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -606,5 +607,49 @@
|
||||
"deleteTitle": "确认删除",
|
||||
"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