diff --git a/desktop/src/pages/SkillCenter.tsx b/desktop/src/pages/SkillCenter.tsx index 5f444f2e..40c67850 100644 --- a/desktop/src/pages/SkillCenter.tsx +++ b/desktop/src/pages/SkillCenter.tsx @@ -117,7 +117,7 @@ export function SkillCenter() { const handleClearSearch = () => { setQuery('') - void fetchItems() + void fetchItems({ query: '' }) } const handleOpenInstalled = (detail: SkillMarketDetail) => { diff --git a/desktop/src/stores/skillMarketStore.test.ts b/desktop/src/stores/skillMarketStore.test.ts index 946af60f..90f35ffc 100644 --- a/desktop/src/stores/skillMarketStore.test.ts +++ b/desktop/src/stores/skillMarketStore.test.ts @@ -89,6 +89,25 @@ describe('skillMarketStore', () => { expect(useSkillMarketStore.getState().error).toBeNull() }) + it('allows callers to override the current query for an immediate refresh', async () => { + mockedSkillMarketApi.list.mockResolvedValue({ + items: [], + nextCursor: null, + source: 'clawhub', + sourceStatus: 'ok', + }) + useSkillMarketStore.setState({ query: 'weather' }) + + await useSkillMarketStore.getState().fetchItems({ query: '' }) + + expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({ + source: 'auto', + sort: 'downloads', + q: undefined, + limit: 100, + }) + }) + it('keeps the resolved fallback source status from the marketplace API', async () => { const item = makeItem({ source: 'skillhub', sourceMode: 'fallback' }) mockedSkillMarketApi.list.mockResolvedValue({ diff --git a/desktop/src/stores/skillMarketStore.ts b/desktop/src/stores/skillMarketStore.ts index c9615c3f..344aeb91 100644 --- a/desktop/src/stores/skillMarketStore.ts +++ b/desktop/src/stores/skillMarketStore.ts @@ -27,7 +27,7 @@ type SkillMarketStore = { setSource: (source: SkillMarketListSource) => void setSort: (sort: SkillMarketSort) => void setQuery: (query: string) => void - fetchItems: () => Promise + fetchItems: (options?: { query?: string }) => Promise fetchMore: () => Promise fetchDetail: (source: SkillMarketSource, slug: string) => Promise installSelected: () => Promise @@ -63,15 +63,16 @@ export const useSkillMarketStore = create((set, get) => ({ }, setQuery: (query) => set({ query }), - fetchItems: async () => { + fetchItems: async (options) => { const { source, sort, query } = get() + const requestedQuery = options?.query ?? query detailRequestSeq += 1 set({ isLoading: true, isLoadingMore: false, isDetailLoading: false, selectedDetail: null, error: null }) try { const result = await skillMarketApi.list({ source, sort, - q: query.trim() || undefined, + q: requestedQuery.trim() || undefined, limit: MARKET_PAGE_LIMIT, }) set({ diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 099a403b..67b2075b 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -510,6 +510,42 @@ describe('skill market service source selection', () => { expect(fetchCalls[0]).toContain('limit=100') }) + it('filters catalog results locally when the upstream source ignores query text', async () => { + const service = createSkillMarketService({ + fetchImpl: async () => Response.json({ + items: [ + { + slug: 'skill-vetter', + displayName: 'Skill Vetter', + summary: 'Security-first skill vetting for AI agents.', + stats: { downloads: 260911, installs: 11988, stars: 1248 }, + tags: { latest: '1.0.0' }, + latestVersion: { version: '1.0.0', license: 'Apache-2.0' }, + }, + { + slug: 'weather', + displayName: 'Weather', + summary: 'Get current weather and forecasts.', + topics: ['forecast'], + stats: { downloads: 162000, installs: 7000, stars: 420 }, + tags: { latest: '1.0.0' }, + latestVersion: { version: '1.0.0', license: 'MIT' }, + }, + ], + nextCursor: 'next-page', + }), + }) + + const result = await service.listSkills({ source: 'clawhub', query: 'weather forecast' }) + + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ + slug: 'weather', + displayName: 'Weather', + }) + expect(result.nextCursor).toBe('next-page') + }) + it('falls back to SkillHub in auto mode when ClawHub fails and marks installed skills', async () => { const fetchCalls: string[] = [] const service = createSkillMarketService({ diff --git a/src/server/services/skillMarket/service.ts b/src/server/services/skillMarket/service.ts index 7ed534da..7954f661 100644 --- a/src/server/services/skillMarket/service.ts +++ b/src/server/services/skillMarket/service.ts @@ -150,7 +150,7 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {} try { const result = await cachedCatalog(cacheKey, async () => { const payload = await requestJson(fetchImpl, url, 'ClawHub') - return normalizeClawHubList(payload) + return filterCatalogByQuery(normalizeClawHubList(payload), params.query) }) failureCache.delete(cacheKey) return result @@ -170,7 +170,7 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {} return cachedCatalog(catalogCacheKey('skillhub', url), async () => { const payload = await requestJson(fetchImpl, url, 'SkillHub') return { - ...normalizeSkillHubList(payload), + ...filterCatalogByQuery(normalizeSkillHubList(payload), params.query), sourceStatus: 'ok', } }) @@ -414,6 +414,38 @@ function catalogCacheKey(source: 'clawhub' | 'skillhub', url: URL): string { return `${source}:${url.toString()}` } +function filterCatalogByQuery(result: SkillMarketListResult, query?: string): SkillMarketListResult { + const terms = normalizeSearchTerms(query) + if (terms.length === 0) { + return result + } + + return { + ...result, + items: result.items.filter((item) => { + const haystack = [ + item.slug, + item.displayName, + item.summary, + item.summaryZh, + item.owner, + item.category, + item.license, + ...(item.tags ?? []), + ].filter(Boolean).join(' ').toLocaleLowerCase() + return terms.every((term) => haystack.includes(term)) + }), + } +} + +function normalizeSearchTerms(query?: string): string[] { + return (query ?? '') + .trim() + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean) +} + async function requestJson(fetchImpl: FetchImpl, url: URL, sourceName: string): Promise { let response: Response try {