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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '新しいスキルを検索して機能を広げましょう。',
|
||||
|
||||
Reference in New Issue
Block a user