From 882da7b904dcd80b1780bc3be2b5269cac7746d6 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:13:49 +0800 Subject: [PATCH] feat: add install skill functionality and enhance marketplace dialog (#427) --- src/i18n/locales/en/skills.json | 9 +- src/i18n/locales/ja/skills.json | 9 +- src/i18n/locales/zh/skills.json | 9 +- src/pages/Skills/index.tsx | 446 +++++++++++++++++--------------- 4 files changed, 254 insertions(+), 219 deletions(-) diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 4aa6c6253..0f8868578 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -18,7 +18,8 @@ "searchButton": "Search", "actions": { "enableVisible": "Enable Visible", - "disableVisible": "Disable Visible" + "disableVisible": "Disable Visible", + "installSkill": "Install Skills" }, "noSkills": "No skills found", "noSkillsSearch": "Try a different search term", @@ -79,6 +80,10 @@ }, "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.", "manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".", "searching": "Searching ClawHub...", @@ -86,4 +91,4 @@ "emptyPrompt": "Search for new skills to expand your capabilities.", "searchError": "ClawHub search failed. Check your connection or installation." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json index 8a6ff767b..0c27b94f0 100644 --- a/src/i18n/locales/ja/skills.json +++ b/src/i18n/locales/ja/skills.json @@ -18,7 +18,8 @@ "searchButton": "検索", "actions": { "enableVisible": "表示中を一括有効化", - "disableVisible": "表示中を一括無効化" + "disableVisible": "表示中を一括無効化", + "installSkill": "スキルをインストール" }, "noSkills": "スキルが見つかりません", "noSkillsSearch": "別の検索語をお試しください", @@ -79,6 +80,10 @@ }, "marketplace": { "title": "マーケットプレイス", + "installDialogTitle": "スキルをインストール", + "installDialogSubtitle": "初期表示は Explore、キーワード入力時は検索します。", + "sourceLabel": "ソース", + "sourceClawHub": "ClawHub", "securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。", "manualInstallHint": "ネットワークに問題がありますか?いつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{{path}}\" に展開してインストールできます。", "searching": "ClawHubを検索中...", @@ -86,4 +91,4 @@ "emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。", "searchError": "ClawHub検索に失敗しました。接続またはインストールを確認してください。" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/skills.json b/src/i18n/locales/zh/skills.json index 7c49be45d..748ff7b66 100644 --- a/src/i18n/locales/zh/skills.json +++ b/src/i18n/locales/zh/skills.json @@ -18,7 +18,8 @@ "searchButton": "搜索", "actions": { "enableVisible": "批量启用可见项", - "disableVisible": "批量禁用可见项" + "disableVisible": "批量禁用可见项", + "installSkill": "安装技能" }, "noSkills": "未找到技能", "noSkillsSearch": "尝试不同的搜索词", @@ -79,6 +80,10 @@ }, "marketplace": { "title": "市场", + "installDialogTitle": "安装技能", + "installDialogSubtitle": "默认展示 Explore,输入关键词后执行搜索。", + "sourceLabel": "来源", + "sourceClawHub": "ClawHub", "securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。", "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。", "searching": "正在搜索 ClawHub...", @@ -86,4 +91,4 @@ "emptyPrompt": "搜索新技能以扩展您的能力。", "searchError": "ClawHub 搜索失败。请检查您的连接或安装。" } -} \ No newline at end of file +} diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index 798ca6b74..11d67d8b1 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -2,7 +2,7 @@ * Skills Page * Browse and manage AI skills */ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Search, Puzzle, @@ -346,25 +346,21 @@ export function Skills() { const { t } = useTranslation('skills'); const gatewayStatus = useGatewayStore((state) => state.status); const [searchQuery, setSearchQuery] = useState(''); - const [marketplaceQuery, setMarketplaceQuery] = useState(''); + const [installQuery, setInstallQuery] = useState(''); + const [installSheetOpen, setInstallSheetOpen] = useState(false); const [selectedSkill, setSelectedSkill] = useState(null); - const [activeTab, setActiveTab] = useState('all'); const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); - const marketplaceDiscoveryAttemptedRef = useRef(false); const isGatewayRunning = gatewayStatus.state === 'running'; const [showGatewayWarning, setShowGatewayWarning] = useState(false); - // Debounce the gateway warning to avoid flickering during brief restarts (like skill toggles) useEffect(() => { let timer: NodeJS.Timeout; if (!isGatewayRunning) { - // Wait 1.5s before showing the warning timer = setTimeout(() => { setShowGatewayWarning(true); }, 1500); } else { - // Use setTimeout to avoid synchronous setState in effect timer = setTimeout(() => { setShowGatewayWarning(false); }, 0); @@ -372,14 +368,12 @@ export function Skills() { return () => clearTimeout(timer); }, [isGatewayRunning]); - // Fetch skills on mount useEffect(() => { if (isGatewayRunning) { fetchSkills(); } }, [fetchSkills, isGatewayRunning]); - // Filter skills const safeSkills = Array.isArray(skills) ? skills : []; const filteredSkills = safeSkills.filter((skill) => { const q = searchQuery.toLowerCase().trim(); @@ -400,13 +394,10 @@ export function Skills() { return matchesSearch && matchesSource; }).sort((a, b) => { - // Enabled skills first 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; - // Finally alphabetical return a.name.localeCompare(b.name); }); @@ -445,7 +436,6 @@ export function Skills() { toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length })); }, [disableSkill, enableSkill, filteredSkills, t]); - // Handle toggle const handleToggle = useCallback(async (skillId: string, enable: boolean) => { try { if (enable) { @@ -470,7 +460,6 @@ export function Skills() { } const result = await invokeIpc('shell:openPath', skillsDir); 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')) { toast.error(t('toast.failedFolderNotFound')); } else { @@ -490,20 +479,26 @@ export function Skills() { .catch(console.error); }, []); - - // Auto-reset when query is cleared useEffect(() => { - if (activeTab === 'marketplace' && marketplaceQuery === '' && marketplaceDiscoveryAttemptedRef.current) { - searchSkills(''); + if (!installSheetOpen) { + 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) => { try { await installSkill(slug); - // Automatically enable after install - // We need to find the skill id which is usually the slug await enableSkill(slug); toast.success(t('toast.installed')); } catch (err) { @@ -516,25 +511,6 @@ export function Skills() { } }, [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) => { try { await uninstallSkill(slug); @@ -597,14 +573,14 @@ export function Skills() { activeTab === 'marketplace' ? setMarketplaceQuery(e.target.value) : 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" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + 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 && ( @@ -635,33 +611,40 @@ export function Skills() {
- {activeTab === 'all' && ( - <> - - - - )} + + + @@ -670,7 +653,7 @@ export function Skills() { {/* Content Area */}
- {error && activeTab === 'all' && ( + {error && (
@@ -682,151 +665,188 @@ export function Skills() { )}
- {activeTab === 'all' && ( - filteredSkills.length === 0 ? ( -
- -

{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}

-
- ) : ( - filteredSkills.map((skill) => ( -
setSelectedSkill(skill)} - > -
-
- {skill.icon || '🧩'} -
-
-
-

{skill.name}

- {skill.isCore ? ( - - ) : skill.isBundled ? ( - - ) : null} - {skill.slug && skill.slug !== skill.name ? ( - - {skill.slug} - - ) : null} -
-

- {skill.description} -

-
-
-
e.stopPropagation()}> - {skill.version && ( - - v{skill.version} - - )} - handleToggle(skill.id, checked)} - disabled={skill.isCore} - /> -
-
- )) - ) - )} - - {activeTab === 'marketplace' && ( -
- {searchError && ( -
- - - {['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError'].includes(searchError.replace('Error: ', '')) - ? t(`toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath }) - : t('marketplace.searchError')} - -
- )} - - {activeTab === 'marketplace' && marketplaceQuery && searching && ( -
- -

{t('marketplace.searching')}

-
- )} - - {searchResults.length > 0 ? ( - searchResults.map((skill) => { - const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name); - const isInstallLoading = !!installing[skill.slug]; - - return ( -
invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`)} - > -
-
- 📦 -
-
-
-

{skill.name}

- {skill.author && ( - • {skill.author} - )} -
-

- {skill.description} -

-
-
-
e.stopPropagation()}> - {skill.version && ( - - v{skill.version} - - )} - {isInstalled ? ( - - ) : ( - - )} -
-
- ); - }) - ) : ( - !searching && marketplaceQuery && ( -
- -

{t('marketplace.noResults')}

-
- ) - )} + {filteredSkills.length === 0 ? ( +
+ +

{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}

+ ) : ( + filteredSkills.map((skill) => ( +
setSelectedSkill(skill)} + > +
+
+ {skill.icon || '🧩'} +
+
+
+

{skill.name}

+ {skill.isCore ? ( + + ) : skill.isBundled ? ( + + ) : null} + {skill.slug && skill.slug !== skill.name ? ( + + {skill.slug} + + ) : null} +
+

+ {skill.description} +

+
+
+
e.stopPropagation()}> + {skill.version && ( + + v{skill.version} + + )} + handleToggle(skill.id, checked)} + disabled={skill.isCore} + /> +
+
+ )) )}
+ + +
+

{t('marketplace.installDialogTitle')}

+

{t('marketplace.installDialogSubtitle')}

+
+
+ + 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 && ( + + )} +
+ +
+
+ +
+ {searchError && ( +
+ + + {['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError'].includes(searchError.replace('Error: ', '')) + ? t(`toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath }) + : t('marketplace.searchError')} + +
+ )} + + {searching && ( +
+ +

{t('marketplace.searching')}

+
+ )} + + {!searching && searchResults.length > 0 && ( +
+ {searchResults.map((skill) => { + const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name); + const isInstallLoading = !!installing[skill.slug]; + + return ( +
invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`)} + > +
+
+ 📦 +
+
+
+

{skill.name}

+ {skill.author && ( + • {skill.author} + )} +
+

+ {skill.description} +

+
+
+
e.stopPropagation()}> + {skill.version && ( + + v{skill.version} + + )} + {isInstalled ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ )} + + {!searching && searchResults.length === 0 && !searchError && ( +
+ +

{installQuery.trim() ? t('marketplace.noResults') : t('marketplace.emptyPrompt')}

+
+ )} +
+
+
+ {/* Skill Detail Dialog */}