feat: Enhance Marketplace and Skill Management UI with improved error handling and user feedback

- Updated MarketplaceDrawer to include security notes and manual installation hints.
- Refactored SkillDetailDrawer to display default icons for skills.
- Simplified SkillListItem to use default icons for better readability.
- Integrated gateway status checks and warnings in SkillsPage for improved user awareness.
- Enhanced error handling for skill installation and fetching, providing clearer feedback to users.
- Added new translations for error messages and gateway warnings to improve localization support.
This commit is contained in:
duanshuwen
2026-04-19 20:33:44 +08:00
parent 2cedc1c234
commit 38bea97197
230 changed files with 77824 additions and 163 deletions

View File

@@ -1,4 +1,5 @@
import { hostApiFetch } from './host-api';
import { gatewayRpc } from './gateway-client';
import type { MarketplaceSkill, Skill } from './skills-types';
type SkillConfigRecord = Record<string, unknown> & {
@@ -26,6 +27,51 @@ type ClawhubListEntry = {
baseDir?: string;
};
type GatewaySkillStatus = {
skillKey: string;
slug?: string;
name?: string;
description?: string;
disabled?: boolean;
emoji?: string;
version?: string;
author?: string;
config?: Record<string, unknown>;
bundled?: boolean;
always?: boolean;
source?: string;
baseDir?: string;
filePath?: string;
};
type GatewaySkillsStatusResult = {
skills?: GatewaySkillStatus[];
};
function mapErrorCodeToSkillErrorKey(
message: string,
operation: 'fetch' | 'search' | 'install',
): string {
const lower = message.toLowerCase();
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('abort')) {
return operation === 'search'
? 'searchTimeoutError'
: operation === 'install'
? 'installTimeoutError'
: 'fetchTimeoutError';
}
if (lower.includes('rate limit') || lower.includes('429') || lower.includes('too many requests')) {
return operation === 'search'
? 'searchRateLimitError'
: operation === 'install'
? 'installRateLimitError'
: 'fetchRateLimitError';
}
return message;
}
function buildSkillsFromConfigs(configs: Record<string, SkillConfigRecord>): Skill[] {
if (Object.keys(configs).length === 0) {
return [];
@@ -41,8 +87,8 @@ function buildSkillsFromConfigs(configs: Record<string, SkillConfigRecord>): Ski
version: cfg.version || '1.0.0',
author: cfg.author,
config: cfg.config || { apiKey: cfg.apiKey, env: cfg.env },
isCore: cfg.isCore || false,
isBundled: cfg.isBundled ?? true,
isCore: Boolean(cfg.isCore),
isBundled: typeof cfg.isBundled === 'boolean' ? cfg.isBundled : cfg.source === 'openclaw-bundled',
source: cfg.source,
baseDir: cfg.baseDir,
filePath: cfg.filePath,
@@ -58,7 +104,7 @@ function buildSkillsFromClawhubList(entries?: ClawhubListEntry[]): Skill[] {
id: item.slug || 'unknown-skill',
slug: item.slug,
name: item.slug || 'unknown-skill',
description: 'Waiting for skill metadata from /api/skills/configs.',
description: 'Recently installed, initializing...',
enabled: false,
version: item.version || 'unknown',
source: item.source || 'openclaw-managed',
@@ -69,57 +115,149 @@ function buildSkillsFromClawhubList(entries?: ClawhubListEntry[]): Skill[] {
}
export async function apiFetchSkills(): Promise<Skill[]> {
const [configsResult, listResult] = await Promise.allSettled([
const [gatewayResult, configsResult, listResult] = await Promise.allSettled([
gatewayRpc<GatewaySkillsStatusResult>('skills.status'),
hostApiFetch<Record<string, SkillConfigRecord>>('/api/skills/configs'),
hostApiFetch<{ success: boolean; results?: ClawhubListEntry[]; error?: string }>('/api/clawhub/list'),
]);
let combinedSkills: Skill[] = [];
if (gatewayResult.status === 'fulfilled' && Array.isArray(gatewayResult.value?.skills)) {
combinedSkills = gatewayResult.value.skills.map((skill) => ({
id: skill.skillKey,
slug: skill.slug || skill.skillKey,
name: skill.name || skill.skillKey,
description: skill.description || '',
enabled: !skill.disabled,
icon: skill.emoji || undefined,
version: skill.version || '1.0.0',
author: skill.author,
config: skill.config || {},
isCore: Boolean(skill.bundled && skill.always),
isBundled: Boolean(skill.bundled),
source: skill.source,
baseDir: skill.baseDir,
filePath: skill.filePath,
}));
}
if (configsResult.status === 'fulfilled' && configsResult.value && typeof configsResult.value === 'object') {
const configuredSkills = buildSkillsFromConfigs(configsResult.value);
if (configuredSkills.length > 0) {
return configuredSkills;
if (combinedSkills.length === 0) {
combinedSkills = configuredSkills;
} else {
const byId = new Map(combinedSkills.map((skill) => [skill.id, skill]));
for (const skill of configuredSkills) {
const current = byId.get(skill.id);
if (current) {
byId.set(skill.id, {
...current,
...skill,
config: {
...(current.config || {}),
...(skill.config || {}),
},
enabled: typeof skill.enabled === 'boolean' ? skill.enabled : current.enabled,
isCore: typeof skill.isCore === 'boolean' ? skill.isCore : current.isCore,
isBundled: typeof skill.isBundled === 'boolean' ? skill.isBundled : current.isBundled,
source: skill.source || current.source,
baseDir: skill.baseDir || current.baseDir,
filePath: skill.filePath || current.filePath,
icon: skill.icon || current.icon,
description: skill.description || current.description,
name: skill.name || current.name,
version: skill.version || current.version,
author: skill.author || current.author,
});
} else {
byId.set(skill.id, skill);
}
}
combinedSkills = [...byId.values()];
}
}
if (listResult.status === 'fulfilled') {
return buildSkillsFromClawhubList(listResult.value?.results);
const listedSkills = buildSkillsFromClawhubList(listResult.value?.results);
const byId = new Map(combinedSkills.map((skill) => [skill.id, skill]));
for (const skill of listedSkills) {
const current = byId.get(skill.id);
if (current) {
byId.set(skill.id, {
...current,
source: current.source || skill.source,
baseDir: current.baseDir || skill.baseDir,
version: current.version || skill.version,
});
} else {
byId.set(skill.id, skill);
}
}
combinedSkills = [...byId.values()];
}
if (configsResult.status === 'fulfilled') {
if (combinedSkills.length > 0) {
return combinedSkills;
}
if (configsResult.status === 'fulfilled' || gatewayResult.status === 'fulfilled') {
return [];
}
const configError = configsResult.reason instanceof Error
? configsResult.reason.message
: String(configsResult.reason);
const errorMessages = [
gatewayResult.status === 'rejected'
? gatewayResult.reason instanceof Error ? gatewayResult.reason.message : String(gatewayResult.reason)
: null,
configsResult.status === 'rejected'
? configsResult.reason instanceof Error ? configsResult.reason.message : String(configsResult.reason)
: null,
].filter(Boolean) as string[];
const listError = listResult.status === 'rejected'
? (listResult.reason instanceof Error ? listResult.reason.message : String(listResult.reason))
: null;
throw new Error(listError ? `${configError}; ${listError}` : configError);
const mergedMessage = [...errorMessages, listError].filter(Boolean).join('; ') || 'Failed to fetch skills';
throw new Error(mapErrorCodeToSkillErrorKey(mergedMessage, 'fetch'));
}
export async function apiSearchSkills(query: string): Promise<MarketplaceSkill[]> {
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
try {
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (!result?.success) {
throw new Error(result?.error || 'ClawHub search failed');
if (!result?.success) {
throw new Error(result?.error || 'ClawHub search failed');
}
return Array.isArray(result.results) ? result.results : [];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(mapErrorCodeToSkillErrorKey(message, 'search'));
}
return Array.isArray(result.results) ? result.results : [];
}
export async function apiInstallSkill(slug: string, version?: string): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
method: 'POST',
body: JSON.stringify({ slug, version }),
});
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
method: 'POST',
body: JSON.stringify({ slug, version }),
});
if (!result?.success) {
throw new Error(result?.error || 'Install failed');
if (!result?.success) {
throw new Error(result?.error || 'Install failed');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(mapErrorCodeToSkillErrorKey(message, 'install'));
}
}
@@ -134,6 +272,17 @@ export async function apiUninstallSkill(slug: string): Promise<void> {
}
}
export async function apiSetSkillEnabled(skillKey: string, enabled: boolean): Promise<void> {
try {
const result = await gatewayRpc<{ success?: boolean }>('skills.update', { skillKey, enabled });
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
throw new Error('Failed to update skill');
}
} catch {
await apiUpdateSkillConfig(skillKey, { enabled });
}
}
export async function apiUpdateSkillConfig(
skillKey: string,
config: { apiKey?: string; env?: Record<string, string>; enabled?: boolean },
@@ -170,14 +319,22 @@ export async function apiOpenSkillReadme(skillKey: string, slug?: string, baseDi
export async function apiGetSkillsDir(): Promise<string> {
try {
const result = await hostApiFetch<{ success: boolean; dir?: string; error?: string }>('/api/clawhub/skills-dir');
if (result?.success && result.dir) return result.dir;
const result = await hostApiFetch<{ success: boolean; dir?: string; path?: string; error?: string }>('/api/clawhub/skills-dir');
if (result?.success && (result.dir || result.path)) return result.dir || result.path || '~/.zn-ai/skills';
} catch {
// Fallback to the default local path.
}
return '~/.zn-ai/skills';
}
export async function apiGetGatewayStatus(): Promise<{
ok: boolean;
status: 'connected' | 'disconnected' | 'reconnecting';
initialized: boolean;
}> {
return hostApiFetch('/api/gateway/status');
}
export async function apiOpenSkillsDir(): Promise<void> {
const dir = await apiGetSkillsDir();
try {

View File

@@ -6,7 +6,6 @@ import {
CloseIcon,
DeleteIcon,
ErrorWarningIcon,
PuzzleIcon,
RefreshIcon,
SearchIcon,
} from './icons';
@@ -16,8 +15,10 @@ type MarketplaceDrawerProps = {
query: string;
searchResults: MarketplaceSkill[];
searching: boolean;
searchError: string | null;
searchErrorLabel: string | null;
installedSkills: Skill[];
installing: Record<string, boolean>;
skillsDirPath: string;
t: SkillsTranslate;
onClose: () => void;
onQueryChange: (value: string) => void;
@@ -35,8 +36,10 @@ export default function MarketplaceDrawer({
query,
searchResults,
searching,
searchError,
searchErrorLabel,
installedSkills,
installing,
skillsDirPath,
t,
onClose,
onQueryChange,
@@ -59,10 +62,6 @@ export default function MarketplaceDrawer({
if (!open) return null;
const searchErrorLabel = searchError
? `${t('skills.marketplace.searchError')} (${searchError})`
: null;
return (
<div
className="absolute inset-0 z-30 flex justify-end bg-black/15 backdrop-blur-[1px]"
@@ -123,6 +122,10 @@ export default function MarketplaceDrawer({
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="mb-4 rounded-xl border border-black/10 bg-black/[0.03] px-4 py-3 text-sm text-[#525866] dark:border-gray-700 dark:bg-white/[0.03] dark:text-gray-300">
{t('skills.marketplace.securityNote', { path: skillsDirPath })}
</div>
{searchErrorLabel ? (
<div className="mb-4 flex items-center gap-2 rounded-xl border border-red-500/50 bg-red-500/10 p-4 text-sm font-medium text-red-600">
<ErrorWarningIcon className="h-5 w-5 shrink-0" />
@@ -141,6 +144,7 @@ export default function MarketplaceDrawer({
<div className="flex flex-col gap-1">
{searchResults.map((skill) => {
const installed = isInstalled(skill, installedSkills);
const isInstalling = Boolean(installing[skill.slug]);
return (
<div
@@ -157,8 +161,8 @@ export default function MarketplaceDrawer({
}}
>
<div className="flex min-w-0 flex-1 items-start gap-4 overflow-hidden pr-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-black/5 bg-black/5 dark:border-gray-700 dark:bg-[#222225]">
<PuzzleIcon className="h-5 w-5 text-[#525866] dark:text-gray-400" />
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-black/5 bg-black/5 text-xl dark:border-gray-700 dark:bg-[#222225]">
📦
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -191,23 +195,25 @@ export default function MarketplaceDrawer({
{installed ? (
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-red-500 text-white transition-colors hover:bg-red-600"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-red-500 text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onUninstall(skill.slug);
}}
disabled={isInstalling}
aria-label={t('skills.detail.uninstall', undefined, 'Uninstall')}
>
<DeleteIcon className="h-3.5 w-3.5" />
{isInstalling ? <RefreshIcon className="h-3.5 w-3.5 animate-spin" /> : <DeleteIcon className="h-3.5 w-3.5" />}
</button>
) : (
<button
type="button"
className="inline-flex h-8 items-center rounded-full bg-[#2B7FFF] px-4 text-xs font-medium text-white transition-colors hover:bg-[#1769e0]"
className="inline-flex h-8 items-center rounded-full bg-[#2B7FFF] px-4 text-xs font-medium text-white transition-colors hover:bg-[#1769e0] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onInstall(skill.slug);
}}
disabled={isInstalling}
>
{t('skills.marketplace.install', undefined, 'Install')}
{isInstalling ? <RefreshIcon className="h-3.5 w-3.5 animate-spin" /> : t('skills.marketplace.install', undefined, 'Install')}
</button>
)}
</div>
@@ -218,10 +224,16 @@ export default function MarketplaceDrawer({
) : null}
{!searching && searchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-[#525866] dark:text-gray-400">
<ArchiveIcon className="mb-4 h-10 w-10 opacity-50" />
<p>{query.trim() ? t('skills.marketplace.noResults') : t('skills.marketplace.emptyPrompt')}</p>
</div>
<>
<div className="flex flex-col items-center justify-center py-20 text-[#525866] dark:text-gray-400">
<ArchiveIcon className="mb-4 h-10 w-10 opacity-50" />
<p>{query.trim() ? t('skills.marketplace.noResults') : t('skills.marketplace.emptyPrompt')}</p>
</div>
<div className="rounded-xl border border-black/10 bg-black/[0.03] px-4 py-3 text-sm text-[#525866] dark:border-gray-700 dark:bg-white/[0.03] dark:text-gray-300">
{t('skills.marketplace.manualInstallHint', { path: skillsDirPath })}
</div>
</>
) : null}
</div>
</aside>

View File

@@ -117,7 +117,7 @@ export default function SkillDetailDrawer({
<div className="flex-1 overflow-y-auto px-8 pb-10 pt-2">
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 flex h-16 w-16 shrink-0 items-center justify-center rounded-full border border-black/5 bg-white text-3xl shadow-sm dark:border-gray-700 dark:bg-[#222225]">
{skill.icon ? skill.icon : <LockIcon className="h-7 w-7 text-[#525866] dark:text-gray-400" />}
<span className="text-3xl leading-none">{skill.icon || '🔧'}</span>
{skill.isCore ? (
<div className="absolute -bottom-1 -right-1 rounded-full border border-black/5 bg-[#f3f1e9] p-1 shadow-sm dark:border-gray-700 dark:bg-[#1b1b1d]">
<LockIcon className="h-3 w-3 text-[#525866] dark:text-gray-400" />

View File

@@ -28,11 +28,7 @@ export default function SkillListItem({ skill, t, onSelect, onToggle }: SkillLis
>
<div className="flex min-w-0 flex-1 items-start gap-4 overflow-hidden pr-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-black/5 bg-black/5 text-2xl dark:border-gray-700 dark:bg-[#222225]">
{skill.icon ? (
<span className="leading-none">{skill.icon}</span>
) : (
<PuzzleIcon className="h-5 w-5 text-[#525866] dark:text-gray-400" />
)}
{skill.icon || '🧩'}
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">

View File

@@ -1,15 +1,18 @@
import { useEffect, useRef, useState } from 'react';
import {
apiFetchSkills,
apiGetGatewayStatus,
apiGetSkillsDir,
apiInstallSkill,
apiOpenSkillPath,
apiOpenSkillReadme,
apiOpenSkillsDir,
apiSetSkillEnabled,
apiSearchSkills,
apiUninstallSkill,
apiUpdateSkillConfig,
} from '@src/lib/skills-api';
import { onGatewayEvent } from '@src/lib/gateway-client';
import type { MarketplaceSkill, Skill } from '@src/lib/skills-types';
import { useSkillsCopy } from './copy';
import { type EnvVarEntry } from './components/EnvVarManager';
@@ -28,6 +31,10 @@ import {
type SkillSourceFilter = 'all' | 'built-in' | 'marketplace';
type FeedbackTone = 'info' | 'success' | 'error';
const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']);
const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']);
const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']);
type FeedbackState = {
id: number;
tone: FeedbackTone;
@@ -49,20 +56,55 @@ export default function SkillsPage() {
const [marketplaceResults, setMarketplaceResults] = useState<MarketplaceSkill[]>([]);
const [marketplaceSearching, setMarketplaceSearching] = useState(false);
const [marketplaceSearchError, setMarketplaceSearchError] = useState<string | null>(null);
const [installing, setInstalling] = useState<Record<string, boolean>>({});
const [skillsDirPath, setSkillsDirPath] = useState('~/.zn-ai/skills');
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isGatewayRunning, setIsGatewayRunning] = useState(false);
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
useEffect(() => {
void loadSkills();
void loadSkillsDir();
void loadGatewayStatus();
const unsubscribeGateway = onGatewayEvent((event) => {
if (event.type === 'gateway:status') {
setIsGatewayRunning(event.status === 'connected');
}
});
return () => {
unsubscribeGateway();
if (feedbackTimerRef.current) {
window.clearTimeout(feedbackTimerRef.current);
}
};
}, []);
useEffect(() => {
let timer: number | null = null;
if (!isGatewayRunning) {
timer = window.setTimeout(() => {
setShowGatewayWarning(true);
}, 1500);
} else {
setShowGatewayWarning(false);
}
return () => {
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [isGatewayRunning]);
useEffect(() => {
if (isGatewayRunning) {
void loadSkills();
}
}, [isGatewayRunning]);
useEffect(() => {
if (!marketplaceOpen) return undefined;
@@ -130,7 +172,11 @@ export default function SkillsPage() {
};
const hasInstalledSkills = skills.some((skill) => !skill.isBundled);
const selectedSkill = skills.find((skill) => skill.id === selectedSkillId) ?? null;
const errorLabel = error ? t(`skills.toast.${error}`, { path: skillsDirPath }, error) : null;
const errorLabel = error
? FETCH_ERROR_CODES.has(error)
? t(`skills.toast.${error}`, { path: skillsDirPath }, error)
: error
: null;
async function loadSkills() {
setLoading(true);
@@ -156,6 +202,15 @@ export default function SkillsPage() {
}
}
async function loadGatewayStatus() {
try {
const status = await apiGetGatewayStatus();
setIsGatewayRunning(Boolean(status.ok) && status.status === 'connected');
} catch {
setIsGatewayRunning(false);
}
}
function pushFeedback(message: string, tone: FeedbackTone = 'info') {
const nextFeedback = { id: Date.now(), tone, message };
setFeedback(nextFeedback);
@@ -184,7 +239,7 @@ export default function SkillsPage() {
updateSkillEnabled(skillId, enabled);
try {
await apiUpdateSkillConfig(skillId, { enabled });
await apiSetSkillEnabled(skillId, enabled);
pushFeedback(enabled ? t('skills.toast.enabled') : t('skills.toast.disabled'), 'success');
} catch (caughtError) {
updateSkillEnabled(skillId, !enabled);
@@ -193,11 +248,10 @@ export default function SkillsPage() {
}
async function bulkToggleVisible(enable: boolean) {
const candidateIds = new Set(filteredSkills
.filter((skill) => !skill.isCore && skill.enabled !== enable)
.map((skill) => skill.id));
const candidates = filteredSkills
.filter((skill) => !skill.isCore && skill.enabled !== enable);
if (candidateIds.size === 0) {
if (candidates.length === 0) {
pushFeedback(
enable ? t('skills.toast.noBatchEnableTargets') : t('skills.toast.noBatchDisableTargets'),
'info',
@@ -205,22 +259,37 @@ export default function SkillsPage() {
return;
}
const previousSkills = skills;
setSkills((currentSkills) => currentSkills.map((skill) => (
candidateIds.has(skill.id) ? { ...skill, enabled: enable } : skill
)));
let successCount = 0;
const failedIds: string[] = [];
try {
await Promise.all(Array.from(candidateIds).map((skillId) => apiUpdateSkillConfig(skillId, { enabled: enable })));
for (const skill of candidates) {
updateSkillEnabled(skill.id, enable);
try {
await apiSetSkillEnabled(skill.id, enable);
successCount += 1;
} catch {
updateSkillEnabled(skill.id, !enable);
failedIds.push(skill.id);
}
}
if (failedIds.length === 0) {
pushFeedback(
enable
? t('skills.toast.batchEnabled', { count: candidateIds.size })
: t('skills.toast.batchDisabled', { count: candidateIds.size }),
? t('skills.toast.batchEnabled', { count: candidates.length })
: t('skills.toast.batchDisabled', { count: candidates.length }),
'success',
);
} catch (caughtError) {
setSkills(previousSkills);
pushFeedback(caughtError instanceof Error ? caughtError.message : String(caughtError), 'error');
} else if (successCount === 0) {
pushFeedback(
enable ? t('skills.toast.failedInstall') : t('skills.toast.failedUninstall'),
'error',
);
} else {
pushFeedback(
t('skills.toast.batchPartial', { success: successCount, total: candidates.length }),
'info',
);
}
}
@@ -241,22 +310,55 @@ export default function SkillsPage() {
return;
}
setInstalling((currentInstalling) => ({
...currentInstalling,
[slug]: true,
}));
try {
await apiInstallSkill(slug, marketplaceSkill.version);
await apiSetSkillEnabled(slug, true);
await loadSkills();
pushFeedback(t('skills.toast.installed'), 'success');
} catch (caughtError) {
pushFeedback(caughtError instanceof Error ? caughtError.message : String(caughtError), 'error');
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
pushFeedback(
INSTALL_ERROR_CODES.has(message)
? t(`skills.toast.${message}`, { path: skillsDirPath }, message)
: `${t('skills.toast.failedInstall')}: ${message}`,
'error',
);
} finally {
setInstalling((currentInstalling) => {
const nextInstalling = { ...currentInstalling };
delete nextInstalling[slug];
return nextInstalling;
});
}
}
async function handleUninstall(slug: string) {
setInstalling((currentInstalling) => ({
...currentInstalling,
[slug]: true,
}));
try {
await apiUninstallSkill(slug);
await loadSkills();
if (selectedSkillId === slug) {
setSelectedSkillId(null);
}
pushFeedback(t('skills.toast.uninstalled'), 'success');
} catch (caughtError) {
pushFeedback(caughtError instanceof Error ? caughtError.message : String(caughtError), 'error');
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
pushFeedback(`${t('skills.toast.failedUninstall')}: ${message}`, 'error');
} finally {
setInstalling((currentInstalling) => {
const nextInstalling = { ...currentInstalling };
delete nextInstalling[slug];
return nextInstalling;
});
}
}
@@ -381,6 +483,15 @@ export default function SkillsPage() {
</div>
</div>
{showGatewayWarning ? (
<div className="mb-6 flex shrink-0 items-center gap-3 rounded-xl border border-yellow-500/50 bg-yellow-500/10 p-4">
<ErrorWarningIcon className="h-5 w-5 shrink-0 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
{t('skills.gatewayWarning')}
</span>
</div>
) : null}
<div className="mb-4 flex shrink-0 flex-col justify-between gap-4 border-b border-black/10 pb-4 dark:border-gray-700 md:flex-row md:items-center">
<div className="flex flex-wrap items-center gap-4 text-[14px]">
<div className="group relative mr-2 flex items-center rounded-full border border-transparent bg-black/5 px-3 py-1.5 transition-colors focus-within:border-black/10 focus-within:bg-black/10 dark:bg-[#222225] dark:focus-within:border-gray-700 dark:focus-within:bg-[#2a2a2d]">
@@ -466,10 +577,11 @@ export default function SkillsPage() {
</button>
<button
type="button"
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-md border border-black/10 bg-transparent text-[#525866] transition-colors hover:bg-black/5 hover:text-[#171717] dark:border-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-md border border-black/10 bg-transparent text-[#525866] transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
onClick={() => {
void loadSkills();
}}
disabled={!isGatewayRunning}
title={t('skills.refresh')}
>
<RefreshIcon className={['h-4 w-4', loading ? 'animate-spin' : ''].join(' ')} />
@@ -521,8 +633,14 @@ export default function SkillsPage() {
query={marketplaceQuery}
searchResults={marketplaceResults}
searching={marketplaceSearching}
searchError={marketplaceSearchError}
searchErrorLabel={marketplaceSearchError
? SEARCH_ERROR_CODES.has(marketplaceSearchError)
? t(`skills.toast.${marketplaceSearchError}`, { path: skillsDirPath }, marketplaceSearchError)
: `${t('skills.marketplace.searchError')} (${marketplaceSearchError})`
: null}
installedSkills={skills}
installing={installing}
skillsDirPath={skillsDirPath}
t={t}
onClose={() => setMarketplaceOpen(false)}
onQueryChange={setMarketplaceQuery}

View File

@@ -8,6 +8,7 @@ export type SkillsMessageTree = {
const en: SkillsMessageTree = {
title: 'Skills',
subtitle: 'Browse and manage AI capabilities',
gatewayWarning: 'Gateway is not running. Skills cannot be loaded without an active Gateway.',
refresh: 'Refresh',
openFolder: 'Open Skills Folder',
filter: {
@@ -68,19 +69,36 @@ const en: SkillsMessageTree = {
installed: 'Skill installed and enabled',
uninstalled: 'Skill uninstalled successfully',
openedEditor: 'Opened in editor',
failedEditor: 'Failed to open editor',
failedSave: 'Failed to save configuration',
failedOpenFolder: 'Failed to open skills folder',
failedInstall: 'Failed to install',
failedUninstall: 'Failed to uninstall',
failedFolderNotFound: 'Skills folder does not exist yet. Install a skill first.',
copiedPath: 'Path copied',
failedCopyPath: 'Failed to copy path',
failedOpenActualFolder: 'Failed to open actual skill folder',
searchTimeoutError: 'Search timed out, check network. You can also search on ClawHub.ai, download the ZIP, and extract it to "{path}"',
installTimeoutError: 'Installation timed out, check network. You can also download the ZIP from ClawHub.ai and extract it to "{path}"',
searchRateLimitError: 'Search rate limit exceeded. You can also search on ClawHub.ai, download the ZIP, and extract it to "{path}"',
installRateLimitError: 'Installation rate limit exceeded. You can also download the ZIP from ClawHub.ai and extract it to "{path}"',
fetchTimeoutError: 'Fetching skills timed out, please check your network connection.',
fetchRateLimitError: 'Fetching skills rate limit exceeded, please try again later.',
timeoutError: 'Request timed out, please try again later.',
rateLimitError: 'Request rate limit exceeded, please try again later.',
noBatchEnableTargets: 'All visible skills are already enabled.',
noBatchDisableTargets: 'All visible skills are already disabled.',
batchEnabled: '{count} skills enabled.',
batchDisabled: '{count} skills disabled.',
batchPartial: 'Updated {success} / {total} skills. Some items failed.',
},
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...',
noResults: 'No skills found matching your search.',
emptyPrompt: 'Search for new skills to expand your capabilities.',
@@ -92,6 +110,7 @@ const en: SkillsMessageTree = {
const zh: SkillsMessageTree = {
title: '技能',
subtitle: '浏览和管理 AI 能力',
gatewayWarning: '网关未运行。没有活跃的网关,无法加载技能。',
refresh: '刷新',
openFolder: '打开技能文件夹',
filter: {
@@ -152,19 +171,36 @@ const zh: SkillsMessageTree = {
installed: '技能已安装并启用',
uninstalled: '技能已成功卸载',
openedEditor: '已在编辑器中打开',
failedEditor: '无法打开编辑器',
failedSave: '保存配置失败',
failedOpenFolder: '无法打开技能文件夹',
failedInstall: '安装失败',
failedUninstall: '卸载失败',
failedFolderNotFound: '技能文件夹尚不存在,请先安装一个技能。',
copiedPath: '路径已复制',
failedCopyPath: '复制路径失败',
failedOpenActualFolder: '打开技能实际目录失败',
searchTimeoutError: '搜索超时,请检查网络。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 "{path}"',
installTimeoutError: '安装超时,请检查网络。您也可在 ClawHub.ai 下载该技能压缩包,解压到 "{path}"',
searchRateLimitError: '搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 "{path}"',
installRateLimitError: '安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 "{path}"',
fetchTimeoutError: '获取技能列表超时,请检查网络。',
fetchRateLimitError: '获取技能列表请求过于频繁,请稍后再试。',
timeoutError: '请求超时,请稍后再试。',
rateLimitError: '请求过于频繁,请稍后再试。',
noBatchEnableTargets: '当前可见技能都已启用。',
noBatchDisableTargets: '当前可见技能都已禁用。',
batchEnabled: '已启用 {count} 个技能。',
batchDisabled: '已禁用 {count} 个技能。',
batchPartial: '已更新 {success} / {total} 个技能,部分操作失败。',
},
marketplace: {
installDialogTitle: '安装技能',
installDialogSubtitle: '默认展示 Explore也可以输入关键词搜索。',
sourceLabel: '来源',
sourceClawHub: 'ClawHub',
securityNote: '安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。',
manualInstallHint: '遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 "{path}" 目录来完成手动安装。',
searching: '正在搜索 ClawHub...',
noResults: '没有找到匹配的技能。',
emptyPrompt: '搜索新技能来扩展你的能力。',
@@ -176,6 +212,7 @@ const zh: SkillsMessageTree = {
const ja: SkillsMessageTree = {
title: 'スキル',
subtitle: 'AI 機能を閲覧して管理',
gatewayWarning: 'ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。',
refresh: '更新',
openFolder: 'スキルフォルダを開く',
filter: {
@@ -236,19 +273,36 @@ const ja: SkillsMessageTree = {
installed: 'スキルをインストールして有効にしました',
uninstalled: 'スキルを正常にアンインストールしました',
openedEditor: 'エディタで開きました',
failedEditor: 'エディターを開けませんでした',
failedSave: '設定の保存に失敗しました',
failedOpenFolder: 'スキルフォルダを開けませんでした',
failedInstall: 'インストールに失敗しました',
failedUninstall: 'アンインストールに失敗しました',
failedFolderNotFound: 'スキルフォルダがまだ存在しません。先にスキルをインストールしてください。',
copiedPath: 'パスをコピーしました',
failedCopyPath: 'パスのコピーに失敗しました',
failedOpenActualFolder: '実際のスキルフォルダを開けませんでした',
searchTimeoutError: '検索がタイムアウトしました。ClawHub.aiで検索してZIPをダウンロードし、"{path}" に展開することも可能です',
installTimeoutError: 'インストールがタイムアウトしました。ClawHub.aiでZIPをダウンロードし、"{path}" に展開することも可能です',
searchRateLimitError: '検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、"{path}" に展開することも可能です',
installRateLimitError: 'インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、"{path}" に展開することも可能です',
fetchTimeoutError: 'スキルリストの取得がタイムアウトしました。ネットワークを確認してください。',
fetchRateLimitError: 'スキルリスト取得のリクエスト制限を超過しました。後でお試しください。',
timeoutError: 'リクエストがタイムアウトしました。後でもう一度お試しください。',
rateLimitError: 'リクエストの制限を超過しました。後でもう一度お試しください。',
noBatchEnableTargets: '表示中のスキルはすべて有効です。',
noBatchDisableTargets: '表示中のスキルはすべて無効です。',
batchEnabled: '{count} 件のスキルを有効化しました。',
batchDisabled: '{count} 件のスキルを無効化しました。',
batchPartial: '{success} / {total} 件を更新しました。一部失敗しています。',
},
marketplace: {
installDialogTitle: 'スキルをインストール',
installDialogSubtitle: '初期表示は Explore、キーワード入力時に検索します。',
sourceLabel: 'ソース',
sourceClawHub: 'ClawHub',
securityNote: 'インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。',
manualInstallHint: 'ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で "{path}" に展開してインストールできます。',
searching: 'ClawHub を検索中...',
noResults: '一致するスキルが見つかりません。',
emptyPrompt: '新しいスキルを検索して機能を広げましょう。',