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 {
|
||||
|
||||
Reference in New Issue
Block a user