feat: enhance ClawdHub integration with paging and retry
Add paging support when loading ClawdHub skills Retry on API errors and throttle for ClawdHub requests Load source content on source change and show loading indicators
This commit is contained in:
@@ -10,11 +10,30 @@ import type {
|
||||
SkillsInstallRequest,
|
||||
SkillsInstallResponse,
|
||||
SkillsInstallError,
|
||||
SkillsCatalogSourceResponse,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: "Anthropic's public skills repository",
|
||||
source: 'anthropics/skills',
|
||||
defaultSubpath: 'skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawdHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
sourceType: 'clawdhub',
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentDirectory = (): string | null => {
|
||||
const opencodeDirectory = opencodeClient.getDirectory();
|
||||
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
|
||||
@@ -38,8 +57,13 @@ export interface SkillsCatalogState {
|
||||
sources: SkillsCatalogSource[];
|
||||
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
||||
selectedSourceId: string | null;
|
||||
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
|
||||
loadedSourceIds: Record<string, boolean>;
|
||||
clawdhubHasMoreBySource: Record<string, boolean>;
|
||||
|
||||
isLoadingCatalog: boolean;
|
||||
isLoadingSource: boolean;
|
||||
isLoadingMore: boolean;
|
||||
isScanning: boolean;
|
||||
isInstalling: boolean;
|
||||
|
||||
@@ -52,6 +76,8 @@ export interface SkillsCatalogState {
|
||||
setSelectedSource: (id: string | null) => void;
|
||||
|
||||
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadMoreClawdHub: () => Promise<boolean>;
|
||||
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
||||
installSkills: (request: SkillsInstallRequest) => Promise<SkillsInstallResponse>;
|
||||
}
|
||||
@@ -59,11 +85,16 @@ export interface SkillsCatalogState {
|
||||
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
sources: [],
|
||||
sources: FALLBACK_SOURCES,
|
||||
itemsBySource: {},
|
||||
selectedSourceId: null,
|
||||
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
|
||||
pageInfoBySource: {},
|
||||
loadedSourceIds: {},
|
||||
clawdhubHasMoreBySource: {},
|
||||
|
||||
isLoadingCatalog: false,
|
||||
isLoadingSource: false,
|
||||
isLoadingMore: false,
|
||||
isScanning: false,
|
||||
isInstalling: false,
|
||||
|
||||
@@ -81,55 +112,64 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
pageInfoBySource: get().pageInfoBySource,
|
||||
loadedSourceIds: get().loadedSourceIds,
|
||||
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
|
||||
};
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}${refresh}`
|
||||
: refresh
|
||||
? `?refresh=true`
|
||||
: '';
|
||||
const refresh = options?.refresh ? '?refresh=true' : '';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`/api/config/skills/catalog${refresh}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const sources = payload.sources || [];
|
||||
const itemsBySource = payload.itemsBySource || {};
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
? currentSelected
|
||||
: (sources[0]?.id ?? null);
|
||||
|
||||
set({ sources, itemsBySource, selectedSourceId });
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
lastError = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
|
||||
throw new Error(lastError.message);
|
||||
}
|
||||
}
|
||||
|
||||
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
||||
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
||||
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
|
||||
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
||||
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
? currentSelected
|
||||
: (sources[0]?.id ?? null);
|
||||
|
||||
set({
|
||||
sources,
|
||||
itemsBySource,
|
||||
pageInfoBySource,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
selectedSourceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = lastError || { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
set({
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
pageInfoBySource: previous.pageInfoBySource,
|
||||
loadedSourceIds: previous.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
|
||||
@@ -139,6 +179,144 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
}
|
||||
},
|
||||
|
||||
loadSource: async (sourceId, options) => {
|
||||
if (!sourceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
set({ isLoadingSource: true, lastCatalogError: null });
|
||||
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await fetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
||||
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
set({
|
||||
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = payload?.items || [];
|
||||
const nextCursor = payload?.nextCursor ?? null;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[sourceId]: items.length > 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({
|
||||
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoadingSource: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreClawdHub: async () => {
|
||||
const selectedSourceId = get().selectedSourceId;
|
||||
if (!selectedSourceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageInfo = get().pageInfoBySource[selectedSourceId];
|
||||
const cursor = pageInfo?.nextCursor || null;
|
||||
|
||||
set({ isLoadingMore: true });
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
|
||||
if (currentDirectory) {
|
||||
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
|
||||
}
|
||||
if (cursor) {
|
||||
parts.push(`cursor=${encodeURIComponent(cursor)}`);
|
||||
}
|
||||
const queryParams = `?${parts.join('&')}`;
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCursor = payload.nextCursor ?? null;
|
||||
const currentItems = get().itemsBySource[selectedSourceId] || [];
|
||||
const items = payload.items || [];
|
||||
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
|
||||
let newCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const key = `${item.sourceId}:${item.skillDir}`;
|
||||
if (!merged.has(key)) {
|
||||
newCount += 1;
|
||||
}
|
||||
merged.set(key, item);
|
||||
}
|
||||
|
||||
const noMore = items.length === 0 || newCount === 0;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: {
|
||||
...state.itemsBySource,
|
||||
[selectedSourceId]: Array.from(merged.values()),
|
||||
},
|
||||
pageInfoBySource: {
|
||||
...state.pageInfoBySource,
|
||||
[selectedSourceId]: { nextCursor },
|
||||
},
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[selectedSourceId]: !noMore,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoadingMore: false });
|
||||
}
|
||||
},
|
||||
|
||||
scanRepo: async (request) => {
|
||||
set({ isScanning: true, lastScanError: null, scanResults: null });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user