fix: make skill marketplace search functional

This commit is contained in:
程序员阿江(Relakkes) 2026-07-04 23:25:03 +08:00
parent 977cd6e87a
commit 790b0925ec
5 changed files with 94 additions and 6 deletions

View File

@ -117,7 +117,7 @@ export function SkillCenter() {
const handleClearSearch = () => {
setQuery('')
void fetchItems()
void fetchItems({ query: '' })
}
const handleOpenInstalled = (detail: SkillMarketDetail) => {

View File

@ -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({

View File

@ -27,7 +27,7 @@ type SkillMarketStore = {
setSource: (source: SkillMarketListSource) => void
setSort: (sort: SkillMarketSort) => void
setQuery: (query: string) => void
fetchItems: () => Promise<void>
fetchItems: (options?: { query?: string }) => Promise<void>
fetchMore: () => Promise<void>
fetchDetail: (source: SkillMarketSource, slug: string) => Promise<void>
installSelected: () => Promise<void>
@ -63,15 +63,16 @@ export const useSkillMarketStore = create<SkillMarketStore>((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({

View File

@ -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({

View File

@ -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<unknown> {
let response: Response
try {