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 {