feat: add Skill Hub feature for managing community skills

- Implemented Skill Hub page with tabs for remote, local, and installed skills.
- Added localization support for Chinese in skill-hub.json.
- Created API routes for fetching remote skills, listing local skills, and managing installed skills.
- Developed functionality for installing and uninstalling skills from both remote and local sources.
- Introduced caching mechanism for remote skills and handling updates for installed skills.
This commit is contained in:
catlog22
2026-02-22 19:02:57 +08:00
parent 87634740a3
commit 367fb94718
23 changed files with 2952 additions and 171 deletions

View File

@@ -352,3 +352,32 @@ export type {
UseUpdateRerankerConfigReturn,
UseCcwToolsListReturn,
} from './useCodexLens';
// ========== Skill Hub ==========
export {
useRemoteSkills,
useLocalSkills,
useInstalledSkills,
useSkillHubUpdates,
useInstallSkill,
useCacheSkill,
useUninstallSkill,
useSkillHubStats,
useSkillHub,
skillHubKeys,
} from './useSkillHub';
export type {
CliType,
SkillSource,
RemoteSkill,
LocalSkill,
InstalledSkill,
RemoteSkillsResponse,
LocalSkillsResponse,
InstalledSkillsResponse,
SkillInstallRequest,
SkillInstallResponse,
SkillCacheRequest,
SkillCacheResponse,
SkillHubStats,
} from './useSkillHub';

View File

@@ -5,7 +5,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
import { toast } from '@/stores/notificationStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -54,7 +53,6 @@ export function useExecuteFlowInSession() {
const projectPath = useWorkflowStore(selectProjectPath);
const handleExecutionMessage = useExecutionMonitorStore((s) => s.handleExecutionMessage);
const setPanelOpen = useExecutionMonitorStore((s) => s.setPanelOpen);
const lockSession = useSessionManagerStore((s) => s.lockSession);
return useMutation({
mutationFn: async (params: {
@@ -89,8 +87,8 @@ export function useExecuteFlowInSession() {
},
});
// Lock the session
lockSession(sessionKey, `Executing workflow: ${flowId}`, executionId);
// Note: Session locking is handled by backend WebSocket broadcast (CLI_SESSION_LOCKED)
// Frontend sessionManagerStore updates state via WebSocket message handler
// Open the execution monitor panel
setPanelOpen(true);

View File

@@ -0,0 +1,418 @@
// ========================================
// Skill Hub Hooks
// ========================================
// React Query hooks for Skill Hub API
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ============================================================================
// API Helper
// ============================================================================
/**
* Get CSRF token from cookie
*/
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Typed fetch wrapper with CSRF token handling
*/
async function fetchApi<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
// Add CSRF token for mutating requests
if (options.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
const csrfToken = getCsrfToken();
if (csrfToken) {
headers.set('X-CSRF-Token', csrfToken);
}
}
// Set content type for JSON requests
if (options.body && typeof options.body === 'string') {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(url, {
...options,
headers,
credentials: 'same-origin',
});
if (!response.ok) {
const error: { message: string; status: number; code?: string } = {
message: response.statusText || 'Request failed',
status: response.status,
};
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
try {
const body = await response.json();
if (body.message) error.message = body.message;
else if (body.error) error.message = body.error;
if (body.code) error.code = body.code;
} catch {
// Silently ignore JSON parse errors
}
}
throw error;
}
// Handle no-content responses
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
// ============================================================================
// Types
// ============================================================================
export type CliType = 'claude' | 'codex';
export type SkillSource = 'remote' | 'local';
export interface RemoteSkill {
id: string;
name: string;
description: string;
version: string;
author: string;
category: string;
tags: string[];
downloadUrl: string;
readmeUrl?: string;
homepage?: string;
license?: string;
updatedAt?: string;
}
export interface LocalSkill {
id: string;
name: string;
folderName: string;
description: string;
version: string;
author?: string;
category?: string;
tags?: string[];
path: string;
source: 'local';
updatedAt: string;
}
export interface InstalledSkill {
id: string;
name: string;
folderName: string;
version: string;
installedAt: string;
installedTo: CliType;
source: SkillSource;
originalId: string;
updatesAvailable?: boolean;
latestVersion?: string;
}
export interface RemoteSkillsResponse {
success: boolean;
data: RemoteSkill[];
meta: {
version: string;
updated_at: string;
source: 'github' | 'http' | 'local';
};
total: number;
timestamp: string;
}
export interface LocalSkillsResponse {
success: boolean;
data: LocalSkill[];
total: number;
timestamp: string;
}
export interface InstalledSkillsResponse {
success: boolean;
data: InstalledSkill[];
total: number;
timestamp: string;
}
export interface SkillInstallRequest {
skillId: string;
cliType: CliType;
source: SkillSource;
customName?: string;
downloadUrl?: string;
}
export interface SkillInstallResponse {
success: boolean;
message: string;
installedPath?: string;
error?: string;
}
export interface SkillCacheRequest {
skillId: string;
downloadUrl: string;
}
export interface SkillCacheResponse {
success: boolean;
message: string;
path?: string;
error?: string;
}
export interface SkillHubStats {
remoteTotal: number;
localTotal: number;
installedTotal: number;
updatesAvailable: number;
claudeInstalled: number;
codexInstalled: number;
}
// ============================================================================
// Query Keys
// ============================================================================
export const skillHubKeys = {
all: ['skill-hub'] as const,
remote: () => [...skillHubKeys.all, 'remote'] as const,
local: () => [...skillHubKeys.all, 'local'] as const,
installed: (checkUpdates?: boolean) => [...skillHubKeys.all, 'installed', checkUpdates] as const,
updates: () => [...skillHubKeys.all, 'updates'] as const,
stats: () => [...skillHubKeys.all, 'stats'] as const,
};
// ============================================================================
// Remote Skills Hook
// ============================================================================
/**
* Fetch remote skills from GitHub/HTTP index
*/
export function useRemoteSkills(enabled = true) {
return useQuery({
queryKey: skillHubKeys.remote(),
queryFn: () => fetchApi<RemoteSkillsResponse>('/api/skill-hub/remote'),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
select: (data) => ({
skills: data.data,
meta: data.meta,
total: data.total,
}),
});
}
// ============================================================================
// Local Skills Hook
// ============================================================================
/**
* Fetch local shared skills
*/
export function useLocalSkills(enabled = true) {
return useQuery({
queryKey: skillHubKeys.local(),
queryFn: () => fetchApi<LocalSkillsResponse>('/api/skill-hub/local'),
enabled,
select: (data) => ({
skills: data.data,
total: data.total,
}),
});
}
// ============================================================================
// Installed Skills Hook
// ============================================================================
/**
* Fetch installed skills from hub
*/
export function useInstalledSkills(options?: { checkUpdates?: boolean; enabled?: boolean }) {
const { checkUpdates = false, enabled = true } = options || {};
return useQuery({
queryKey: skillHubKeys.installed(checkUpdates),
queryFn: () => {
const url = checkUpdates
? '/api/skill-hub/installed?checkUpdates=true'
: '/api/skill-hub/installed';
return fetchApi<InstalledSkillsResponse>(url);
},
enabled,
select: (data) => ({
skills: data.data,
total: data.total,
}),
});
}
// ============================================================================
// Updates Check Hook
// ============================================================================
/**
* Check for available updates
*/
export function useSkillHubUpdates(enabled = true) {
return useQuery({
queryKey: skillHubKeys.updates(),
queryFn: () => fetchApi<InstalledSkillsResponse>('/api/skill-hub/updates'),
enabled,
staleTime: 60 * 1000, // 1 minute
select: (data) => ({
updates: data.data,
total: data.total,
}),
});
}
// ============================================================================
// Install Skill Mutation
// ============================================================================
/**
* Install skill mutation
*/
export function useInstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: SkillInstallRequest) =>
fetchApi<SkillInstallResponse>('/api/skill-hub/install', {
method: 'POST',
body: JSON.stringify(request),
}),
onSuccess: () => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: skillHubKeys.installed() });
queryClient.invalidateQueries({ queryKey: skillHubKeys.updates() });
queryClient.invalidateQueries({ queryKey: skillHubKeys.stats() });
},
});
}
// ============================================================================
// Cache Skill Mutation
// ============================================================================
/**
* Cache remote skill mutation
*/
export function useCacheSkill() {
return useMutation({
mutationFn: (request: SkillCacheRequest) =>
fetchApi<SkillCacheResponse>('/api/skill-hub/cache', {
method: 'POST',
body: JSON.stringify(request),
}),
});
}
// ============================================================================
// Uninstall Skill Mutation
// ============================================================================
/**
* Uninstall skill mutation
*/
export function useUninstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillId, cliType }: { skillId: string; cliType: CliType }) =>
fetchApi<{ success: boolean; message: string }>(`/api/skill-hub/installed/${skillId}`, {
method: 'DELETE',
body: JSON.stringify({ cliType }),
}),
onSuccess: () => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: skillHubKeys.installed() });
queryClient.invalidateQueries({ queryKey: skillHubKeys.updates() });
queryClient.invalidateQueries({ queryKey: skillHubKeys.stats() });
},
});
}
// ============================================================================
// Stats Hook
// ============================================================================
/**
* Get skill hub statistics
* Combines data from multiple endpoints
*/
export function useSkillHubStats(enabled = true) {
const { data: remoteData } = useRemoteSkills(enabled);
const { data: localData } = useLocalSkills(enabled);
const { data: installedData } = useInstalledSkills({ checkUpdates: true, enabled });
return useQuery({
queryKey: skillHubKeys.stats(),
queryFn: (): SkillHubStats => {
const installed = installedData?.skills || [];
const updatesAvailable = installed.filter(s => s.updatesAvailable).length;
const claudeInstalled = installed.filter(s => s.installedTo === 'claude').length;
const codexInstalled = installed.filter(s => s.installedTo === 'codex').length;
return {
remoteTotal: remoteData?.total || 0,
localTotal: localData?.total || 0,
installedTotal: installed.length,
updatesAvailable,
claudeInstalled,
codexInstalled,
};
},
enabled: enabled && !!remoteData && !!localData && !!installedData,
staleTime: 30 * 1000, // 30 seconds
});
}
// ============================================================================
// Combined Hooks
// ============================================================================
/**
* Combined hook for all skill hub data
*/
export function useSkillHub(enabled = true) {
const remote = useRemoteSkills(enabled);
const local = useLocalSkills(enabled);
const installed = useInstalledSkills({ checkUpdates: true, enabled });
const stats = useSkillHubStats(enabled && remote.isSuccess && local.isSuccess && installed.isSuccess);
const isLoading = remote.isLoading || local.isLoading || installed.isLoading;
const isError = remote.isError || local.isError || installed.isError;
const isFetching = remote.isFetching || local.isFetching || installed.isFetching;
return {
remote,
local,
installed,
stats,
isLoading,
isError,
isFetching,
refetchAll: () => {
remote.refetch();
local.refetch();
installed.refetch();
},
};
}