feat: add install skill functionality and enhance marketplace dialog (#427)

This commit is contained in:
Felix
2026-03-12 11:13:49 +08:00
committed by GitHub
Unverified
parent 5c07ad77fc
commit 882da7b904
4 changed files with 254 additions and 219 deletions

View File

@@ -18,7 +18,8 @@
"searchButton": "Search", "searchButton": "Search",
"actions": { "actions": {
"enableVisible": "Enable Visible", "enableVisible": "Enable Visible",
"disableVisible": "Disable Visible" "disableVisible": "Disable Visible",
"installSkill": "Install Skills"
}, },
"noSkills": "No skills found", "noSkills": "No skills found",
"noSkillsSearch": "Try a different search term", "noSkillsSearch": "Try a different search term",
@@ -79,6 +80,10 @@
}, },
"marketplace": { "marketplace": {
"title": "Marketplace", "title": "Marketplace",
"installDialogTitle": "Install Skills",
"installDialogSubtitle": "Browse Explore by default, or enter keywords to search.",
"sourceLabel": "Source",
"sourceClawHub": "ClawHub",
"securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.", "securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.",
"manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".", "manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".",
"searching": "Searching ClawHub...", "searching": "Searching ClawHub...",

View File

@@ -18,7 +18,8 @@
"searchButton": "検索", "searchButton": "検索",
"actions": { "actions": {
"enableVisible": "表示中を一括有効化", "enableVisible": "表示中を一括有効化",
"disableVisible": "表示中を一括無効化" "disableVisible": "表示中を一括無効化",
"installSkill": "スキルをインストール"
}, },
"noSkills": "スキルが見つかりません", "noSkills": "スキルが見つかりません",
"noSkillsSearch": "別の検索語をお試しください", "noSkillsSearch": "別の検索語をお試しください",
@@ -79,6 +80,10 @@
}, },
"marketplace": { "marketplace": {
"title": "マーケットプレイス", "title": "マーケットプレイス",
"installDialogTitle": "スキルをインストール",
"installDialogSubtitle": "初期表示は Explore、キーワード入力時は検索します。",
"sourceLabel": "ソース",
"sourceClawHub": "ClawHub",
"securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。", "securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。",
"manualInstallHint": "ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{{path}}\" に展開してインストールできます。", "manualInstallHint": "ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{{path}}\" に展開してインストールできます。",
"searching": "ClawHubを検索中...", "searching": "ClawHubを検索中...",

View File

@@ -18,7 +18,8 @@
"searchButton": "搜索", "searchButton": "搜索",
"actions": { "actions": {
"enableVisible": "批量启用可见项", "enableVisible": "批量启用可见项",
"disableVisible": "批量禁用可见项" "disableVisible": "批量禁用可见项",
"installSkill": "安装技能"
}, },
"noSkills": "未找到技能", "noSkills": "未找到技能",
"noSkillsSearch": "尝试不同的搜索词", "noSkillsSearch": "尝试不同的搜索词",
@@ -79,6 +80,10 @@
}, },
"marketplace": { "marketplace": {
"title": "市场", "title": "市场",
"installDialogTitle": "安装技能",
"installDialogSubtitle": "默认展示 Explore输入关键词后执行搜索。",
"sourceLabel": "来源",
"sourceClawHub": "ClawHub",
"securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。", "securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。",
"manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。", "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。",
"searching": "正在搜索 ClawHub...", "searching": "正在搜索 ClawHub...",

View File

@@ -2,7 +2,7 @@
* Skills Page * Skills Page
* Browse and manage AI skills * Browse and manage AI skills
*/ */
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { import {
Search, Search,
Puzzle, Puzzle,
@@ -346,25 +346,21 @@ export function Skills() {
const { t } = useTranslation('skills'); const { t } = useTranslation('skills');
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [marketplaceQuery, setMarketplaceQuery] = useState(''); const [installQuery, setInstallQuery] = useState('');
const [installSheetOpen, setInstallSheetOpen] = useState(false);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null); const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [activeTab, setActiveTab] = useState('all');
const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all');
const marketplaceDiscoveryAttemptedRef = useRef(false);
const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayRunning = gatewayStatus.state === 'running';
const [showGatewayWarning, setShowGatewayWarning] = useState(false); const [showGatewayWarning, setShowGatewayWarning] = useState(false);
// Debounce the gateway warning to avoid flickering during brief restarts (like skill toggles)
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (!isGatewayRunning) { if (!isGatewayRunning) {
// Wait 1.5s before showing the warning
timer = setTimeout(() => { timer = setTimeout(() => {
setShowGatewayWarning(true); setShowGatewayWarning(true);
}, 1500); }, 1500);
} else { } else {
// Use setTimeout to avoid synchronous setState in effect
timer = setTimeout(() => { timer = setTimeout(() => {
setShowGatewayWarning(false); setShowGatewayWarning(false);
}, 0); }, 0);
@@ -372,14 +368,12 @@ export function Skills() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isGatewayRunning]); }, [isGatewayRunning]);
// Fetch skills on mount
useEffect(() => { useEffect(() => {
if (isGatewayRunning) { if (isGatewayRunning) {
fetchSkills(); fetchSkills();
} }
}, [fetchSkills, isGatewayRunning]); }, [fetchSkills, isGatewayRunning]);
// Filter skills
const safeSkills = Array.isArray(skills) ? skills : []; const safeSkills = Array.isArray(skills) ? skills : [];
const filteredSkills = safeSkills.filter((skill) => { const filteredSkills = safeSkills.filter((skill) => {
const q = searchQuery.toLowerCase().trim(); const q = searchQuery.toLowerCase().trim();
@@ -400,13 +394,10 @@ export function Skills() {
return matchesSearch && matchesSource; return matchesSearch && matchesSource;
}).sort((a, b) => { }).sort((a, b) => {
// Enabled skills first
if (a.enabled && !b.enabled) return -1; if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1; if (!a.enabled && b.enabled) return 1;
// Then core/bundled
if (a.isCore && !b.isCore) return -1; if (a.isCore && !b.isCore) return -1;
if (!a.isCore && b.isCore) return 1; if (!a.isCore && b.isCore) return 1;
// Finally alphabetical
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
@@ -445,7 +436,6 @@ export function Skills() {
toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length })); toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length }));
}, [disableSkill, enableSkill, filteredSkills, t]); }, [disableSkill, enableSkill, filteredSkills, t]);
// Handle toggle
const handleToggle = useCallback(async (skillId: string, enable: boolean) => { const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
try { try {
if (enable) { if (enable) {
@@ -470,7 +460,6 @@ export function Skills() {
} }
const result = await invokeIpc<string>('shell:openPath', skillsDir); const result = await invokeIpc<string>('shell:openPath', skillsDir);
if (result) { if (result) {
// shell.openPath returns an error string if the path doesn't exist
if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) { if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) {
toast.error(t('toast.failedFolderNotFound')); toast.error(t('toast.failedFolderNotFound'));
} else { } else {
@@ -490,20 +479,26 @@ export function Skills() {
.catch(console.error); .catch(console.error);
}, []); }, []);
// Auto-reset when query is cleared
useEffect(() => { useEffect(() => {
if (activeTab === 'marketplace' && marketplaceQuery === '' && marketplaceDiscoveryAttemptedRef.current) { if (!installSheetOpen) {
searchSkills(''); return;
} }
}, [marketplaceQuery, activeTab, searchSkills]);
// Handle install const query = installQuery.trim();
if (query.length === 0) {
searchSkills('');
return;
}
const timer = setTimeout(() => {
searchSkills(query);
}, 300);
return () => clearTimeout(timer);
}, [installQuery, installSheetOpen, searchSkills]);
const handleInstall = useCallback(async (slug: string) => { const handleInstall = useCallback(async (slug: string) => {
try { try {
await installSkill(slug); await installSkill(slug);
// Automatically enable after install
// We need to find the skill id which is usually the slug
await enableSkill(slug); await enableSkill(slug);
toast.success(t('toast.installed')); toast.success(t('toast.installed'));
} catch (err) { } catch (err) {
@@ -516,25 +511,6 @@ export function Skills() {
} }
}, [installSkill, enableSkill, t, skillsDirPath]); }, [installSkill, enableSkill, t, skillsDirPath]);
// Initial marketplace load (Discovery)
useEffect(() => {
if (activeTab !== 'marketplace') {
return;
}
if (marketplaceQuery.trim()) {
return;
}
if (searching) {
return;
}
if (marketplaceDiscoveryAttemptedRef.current) {
return;
}
marketplaceDiscoveryAttemptedRef.current = true;
searchSkills('');
}, [activeTab, marketplaceQuery, searching, searchSkills]);
// Handle uninstall
const handleUninstall = useCallback(async (slug: string) => { const handleUninstall = useCallback(async (slug: string) => {
try { try {
await uninstallSkill(slug); await uninstallSkill(slug);
@@ -597,14 +573,14 @@ export function Skills() {
<Search className="h-4 w-4 shrink-0 text-muted-foreground" /> <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input <input
placeholder={t('search')} placeholder={t('search')}
value={activeTab === 'marketplace' ? marketplaceQuery : searchQuery} value={searchQuery}
onChange={(e) => activeTab === 'marketplace' ? setMarketplaceQuery(e.target.value) : setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="ml-2 bg-transparent outline-none w-24 focus:w-40 md:focus:w-56 transition-all font-normal placeholder:text-foreground/50 text-[13px] text-foreground" className="ml-2 bg-transparent outline-none w-28 md:w-40 font-normal placeholder:text-foreground/50 text-[13px] text-foreground"
/> />
{((activeTab === 'marketplace' && marketplaceQuery) || (activeTab === 'all' && searchQuery)) && ( {searchQuery && (
<button <button
type="button" type="button"
onClick={() => activeTab === 'marketplace' ? setMarketplaceQuery('') : setSearchQuery('')} onClick={() => setSearchQuery('')}
className="text-foreground/50 hover:text-foreground shrink-0 ml-1" className="text-foreground/50 hover:text-foreground shrink-0 ml-1"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@@ -614,20 +590,20 @@ export function Skills() {
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<button <button
onClick={() => { setActiveTab('all'); setSelectedSource('all'); }} onClick={() => setSelectedSource('all')}
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'all' && selectedSource === 'all' ? "text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'all' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
> >
{t('filter.all', { count: sourceStats.all })} {t('filter.all', { count: sourceStats.all })}
</button> </button>
<button <button
onClick={() => { setActiveTab('all'); setSelectedSource('built-in'); }} onClick={() => setSelectedSource('built-in')}
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'all' && selectedSource === 'built-in' ? "text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'built-in' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
> >
{t('filter.builtIn', { count: sourceStats.builtIn })} {t('filter.builtIn', { count: sourceStats.builtIn })}
</button> </button>
<button <button
onClick={() => setActiveTab('marketplace')} onClick={() => setSelectedSource('marketplace')}
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'marketplace' ? "text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'marketplace' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
> >
{t('filter.marketplace', { count: sourceStats.marketplace })} {t('filter.marketplace', { count: sourceStats.marketplace })}
</button> </button>
@@ -635,8 +611,6 @@ export function Skills() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{activeTab === 'all' && (
<>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -653,15 +627,24 @@ export function Skills() {
> >
{t('actions.disableVisible')} {t('actions.disableVisible')}
</Button> </Button>
</> <Button
)} variant="outline"
size="sm"
onClick={() => {
setInstallQuery('');
setInstallSheetOpen(true);
}}
className="h-8 text-[13px] font-medium rounded-md px-3 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none"
>
{t('actions.installSkill')}
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={fetchSkills} onClick={fetchSkills}
disabled={!isGatewayRunning} disabled={!isGatewayRunning}
className="h-8 w-8 ml-1 rounded-md border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-muted-foreground hover:text-foreground" className="h-8 w-8 ml-1 rounded-md border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-muted-foreground hover:text-foreground"
title="Refresh" title={t('refresh')}
> >
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} /> <RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button> </Button>
@@ -670,7 +653,7 @@ export function Skills() {
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2"> <div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
{error && activeTab === 'all' && ( {error && (
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2"> <div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
<AlertCircle className="h-5 w-5 shrink-0" /> <AlertCircle className="h-5 w-5 shrink-0" />
<span> <span>
@@ -682,8 +665,7 @@ export function Skills() {
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{activeTab === 'all' && ( {filteredSkills.length === 0 ? (
filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Puzzle className="h-10 w-10 mb-4 opacity-50" /> <Puzzle className="h-10 w-10 mb-4 opacity-50" />
<p>{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}</p> <p>{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}</p>
@@ -732,11 +714,49 @@ export function Skills() {
</div> </div>
</div> </div>
)) ))
)
)} )}
</div>
</div>
</div>
{activeTab === 'marketplace' && ( <Sheet open={installSheetOpen} onOpenChange={setInstallSheetOpen}>
<div className="flex flex-col gap-1 mt-2"> <SheetContent
className="w-full sm:max-w-[560px] p-0 flex flex-col border-l border-black/10 dark:border-white/10 bg-[#f3f1e9] dark:bg-card shadow-[0_0_40px_rgba(0,0,0,0.2)]"
side="right"
>
<div className="px-7 py-6 border-b border-black/10 dark:border-white/10">
<h2 className="text-[24px] font-serif text-foreground font-normal tracking-tight">{t('marketplace.installDialogTitle')}</h2>
<p className="mt-1 text-[13px] text-foreground/70">{t('marketplace.installDialogSubtitle')}</p>
<div className="mt-4 flex flex-col md:flex-row gap-2">
<div className="relative flex items-center bg-black/5 dark:bg-white/5 rounded-xl px-3 py-2 border border-black/10 dark:border-white/10 flex-1">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
placeholder={t('searchMarketplace')}
value={installQuery}
onChange={(e) => setInstallQuery(e.target.value)}
className="ml-2 h-auto border-0 bg-transparent p-0 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 text-[13px]"
/>
{installQuery && (
<button
type="button"
onClick={() => setInstallQuery('')}
className="text-foreground/50 hover:text-foreground shrink-0 ml-1"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
variant="outline"
disabled
className="h-10 rounded-xl border-black/10 dark:border-white/10 bg-transparent text-muted-foreground"
>
{t('marketplace.sourceLabel')}: {t('marketplace.sourceClawHub')}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{searchError && ( {searchError && (
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2"> <div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
<AlertCircle className="h-5 w-5 shrink-0" /> <AlertCircle className="h-5 w-5 shrink-0" />
@@ -748,15 +768,16 @@ export function Skills() {
</div> </div>
)} )}
{activeTab === 'marketplace' && marketplaceQuery && searching && ( {searching && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<LoadingSpinner size="lg" /> <LoadingSpinner size="lg" />
<p className="mt-4 text-sm">{t('marketplace.searching')}</p> <p className="mt-4 text-sm">{t('marketplace.searching')}</p>
</div> </div>
)} )}
{searchResults.length > 0 ? ( {!searching && searchResults.length > 0 && (
searchResults.map((skill) => { <div className="flex flex-col gap-1">
{searchResults.map((skill) => {
const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name); const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name);
const isInstallLoading = !!installing[skill.slug]; const isInstallLoading = !!installing[skill.slug];
@@ -812,20 +833,19 @@ export function Skills() {
</div> </div>
</div> </div>
); );
}) })}
) : ( </div>
!searching && marketplaceQuery && ( )}
{!searching && searchResults.length === 0 && !searchError && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Package className="h-10 w-10 mb-4 opacity-50" /> <Package className="h-10 w-10 mb-4 opacity-50" />
<p>{t('marketplace.noResults')}</p> <p>{installQuery.trim() ? t('marketplace.noResults') : t('marketplace.emptyPrompt')}</p>
</div>
)
)}
</div> </div>
)} )}
</div> </div>
</div> </SheetContent>
</div> </Sheet>
{/* Skill Detail Dialog */} {/* Skill Detail Dialog */}
<SkillDetailDialog <SkillDetailDialog