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:
@@ -147,29 +147,6 @@ const permissionConfigToRuleset = (value: unknown): PermissionRule[] => {
|
||||
return rules;
|
||||
};
|
||||
|
||||
const buildPermissionConfigFromRules = (ruleset: PermissionRule[]): AgentConfig['permission'] | undefined => {
|
||||
const normalized = normalizeRuleset(ruleset);
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const grouped: Record<string, Record<string, PermissionAction>> = {};
|
||||
for (const rule of normalized) {
|
||||
(grouped[rule.permission] ||= {})[rule.pattern] = rule.action;
|
||||
}
|
||||
|
||||
const result: Record<string, PermissionConfigValue> = {};
|
||||
for (const [permissionName, patterns] of Object.entries(grouped)) {
|
||||
if (Object.keys(patterns).length === 1 && patterns['*']) {
|
||||
result[permissionName] = patterns['*'];
|
||||
} else {
|
||||
result[permissionName] = patterns;
|
||||
}
|
||||
}
|
||||
|
||||
return result as AgentConfig['permission'];
|
||||
};
|
||||
|
||||
const buildPermissionConfigWithGlobal = (
|
||||
globalAction: PermissionAction,
|
||||
ruleset: PermissionRule[],
|
||||
@@ -201,23 +178,6 @@ const buildPermissionConfigWithGlobal = (
|
||||
return result as AgentConfig['permission'];
|
||||
};
|
||||
|
||||
const buildPermissionDiffConfig = (
|
||||
baselineRules: PermissionRule[],
|
||||
currentRules: PermissionRule[],
|
||||
): AgentConfig['permission'] | undefined => {
|
||||
const baselineMap = buildRuleMap(baselineRules);
|
||||
const currentMap = buildRuleMap(currentRules);
|
||||
const changedRules: PermissionRule[] = [];
|
||||
|
||||
for (const [key, rule] of currentMap.entries()) {
|
||||
const baselineRule = baselineMap.get(key);
|
||||
if (!baselineRule || baselineRule.action !== rule.action) {
|
||||
changedRules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return buildPermissionConfigFromRules(changedRules);
|
||||
};
|
||||
|
||||
export const AgentsPage: React.FC = () => {
|
||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
||||
@@ -324,12 +284,6 @@ export const AgentsPage: React.FC = () => {
|
||||
currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action
|
||||
), [currentRuleMap]);
|
||||
|
||||
const getEffectiveWildcardAction = React.useCallback((permissionName: string): PermissionAction => {
|
||||
if (permissionName === '*') {
|
||||
return globalPermission;
|
||||
}
|
||||
return getWildcardOverride(permissionName) ?? globalPermission;
|
||||
}, [getWildcardOverride, globalPermission]);
|
||||
|
||||
const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => (
|
||||
permissionRules
|
||||
@@ -348,12 +302,6 @@ export const AgentsPage: React.FC = () => {
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}, [knownPermissionNames]);
|
||||
|
||||
const getFallbackDefaultAction = React.useCallback((permissionName: string): PermissionAction => {
|
||||
if (permissionName === 'doom_loop' || permissionName === 'external_directory') {
|
||||
return 'ask';
|
||||
}
|
||||
return 'allow';
|
||||
}, []);
|
||||
|
||||
const getPermissionSummary = React.useCallback((permissionName: string) => {
|
||||
const defaultAction = permissionName === '*'
|
||||
|
||||
@@ -65,7 +65,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
selectedSourceId,
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
loadSource,
|
||||
loadMoreClawdHub,
|
||||
isLoadingCatalog,
|
||||
isLoadingSource,
|
||||
isLoadingMore,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore();
|
||||
|
||||
@@ -79,6 +85,15 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
return;
|
||||
}
|
||||
if (!loadedSourceIds[selectedSourceId]) {
|
||||
void loadSource(selectedSourceId);
|
||||
}
|
||||
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
@@ -98,6 +113,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||
|
||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
||||
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
|
||||
const hasMoreClawdHub = Boolean(
|
||||
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
|
||||
);
|
||||
|
||||
const removeSelectedCatalog = async () => {
|
||||
if (!selectedSourceId || !isCustomSource) {
|
||||
@@ -167,8 +186,14 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadCatalog({ refresh: true })}
|
||||
disabled={isLoadingCatalog}
|
||||
onClick={() => {
|
||||
if (selectedSourceId) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
@@ -219,81 +244,99 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skills…</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
<>
|
||||
{filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.sourceId}:${item.skillDir}`}
|
||||
className="rounded-lg border bg-muted/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installedScope || 'unknown'})
|
||||
</span>
|
||||
) : null}
|
||||
{!item.installable ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
not installable
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.clawdhub ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
|
||||
{item.clawdhub.owner ? (
|
||||
<span>by {item.clawdhub.owner}</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RiStarLine className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
return (
|
||||
<div
|
||||
key={`${item.sourceId}:${item.skillDir}`}
|
||||
className="rounded-lg border bg-muted/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installedScope || 'unknown'})
|
||||
</span>
|
||||
) : null}
|
||||
{!item.installable ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
not installable
|
||||
</span>
|
||||
) : null}
|
||||
<span>v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.clawdhub ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
|
||||
{item.clawdhub.owner ? (
|
||||
<span>by {item.clawdhub.owner}</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RiStarLine className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
</span>
|
||||
) : null}
|
||||
<span>v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isClawdHubSource && hasMoreClawdHub ? (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore || isLoadingSource}
|
||||
>
|
||||
{isLoadingMore ? 'Loading…' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -828,6 +828,14 @@ export interface SkillsCatalogResponse {
|
||||
ok: boolean;
|
||||
sources?: SkillsCatalogSource[];
|
||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
export interface SkillsCatalogSourceResponse {
|
||||
ok: boolean;
|
||||
items?: SkillsCatalogItem[];
|
||||
nextCursor?: string | null;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
|
||||
@@ -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