mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: add skill market source fallback
This commit is contained in:
parent
7a35116ee5
commit
296f5c82e2
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
|
||||
import { createSkillMarketService } from '../services/skillMarket/service.js'
|
||||
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
|
||||
import {
|
||||
CLAWHUB_SCAN_RESPONSE,
|
||||
@ -386,3 +387,130 @@ describe('skill market source normalization', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill market service source selection', () => {
|
||||
it('uses ClawHub only in auto mode when ClawHub succeeds and marks installed skills', async () => {
|
||||
const fetchCalls: string[] = []
|
||||
const service = createSkillMarketService({
|
||||
fetchImpl: async (url) => {
|
||||
fetchCalls.push(String(url))
|
||||
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
|
||||
},
|
||||
installedSkillNames: async () => new Set(['skill-vetter']),
|
||||
})
|
||||
|
||||
const result = await service.listSkills({ source: 'auto', limit: 12, query: 'vetter', cursor: 'next-page' })
|
||||
|
||||
expect(fetchCalls).toHaveLength(1)
|
||||
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
|
||||
expect(fetchCalls[0]).toContain('sort=downloads')
|
||||
expect(fetchCalls[0]).toContain('nonSuspiciousOnly=true')
|
||||
expect(fetchCalls[0]).toContain('limit=12')
|
||||
expect(fetchCalls[0]).toContain('query=vetter')
|
||||
expect(fetchCalls[0]).toContain('cursor=next-page')
|
||||
expect(result).toMatchObject({
|
||||
source: 'clawhub',
|
||||
sourceStatus: 'ok',
|
||||
items: [
|
||||
{
|
||||
source: 'clawhub',
|
||||
slug: 'skill-vetter',
|
||||
installed: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to SkillHub in auto mode when ClawHub fails and marks installed skills', async () => {
|
||||
const fetchCalls: string[] = []
|
||||
const service = createSkillMarketService({
|
||||
fetchImpl: async (url) => {
|
||||
fetchCalls.push(String(url))
|
||||
if (String(url).startsWith('https://clawhub.ai/')) {
|
||||
return new Response('temporarily unavailable', { status: 503 })
|
||||
}
|
||||
return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE)
|
||||
},
|
||||
installedSkillNames: new Set(['skill-vetter']),
|
||||
})
|
||||
|
||||
const result = await service.listSkills({ source: 'auto', limit: 10, query: 'vetter', cursor: '3' })
|
||||
|
||||
expect(fetchCalls).toHaveLength(2)
|
||||
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
|
||||
expect(fetchCalls[1]).toStartWith('https://api.skillhub.cn/api/skills')
|
||||
expect(fetchCalls[1]).toContain('sortBy=downloads')
|
||||
expect(fetchCalls[1]).toContain('order=desc')
|
||||
expect(fetchCalls[1]).toContain('limit=10')
|
||||
expect(fetchCalls[1]).toContain('query=vetter')
|
||||
expect(fetchCalls[1]).toContain('cursor=3')
|
||||
expect(result.source).toBe('skillhub')
|
||||
expect(result.sourceStatus).toBe('fallback')
|
||||
expect(result.message).toContain('ClawHub unavailable')
|
||||
expect(result.items[0]).toMatchObject({
|
||||
source: 'skillhub',
|
||||
slug: 'skill-vetter',
|
||||
installed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not fallback to SkillHub when source is 'clawhub'", async () => {
|
||||
const fetchCalls: string[] = []
|
||||
const service = createSkillMarketService({
|
||||
fetchImpl: async (url) => {
|
||||
fetchCalls.push(String(url))
|
||||
return new Response('temporarily unavailable', { status: 503 })
|
||||
},
|
||||
installedSkillNames: async () => new Set(),
|
||||
})
|
||||
|
||||
await expect(service.listSkills({ source: 'clawhub' })).rejects.toThrow('ClawHub request failed')
|
||||
|
||||
expect(fetchCalls).toHaveLength(1)
|
||||
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
|
||||
})
|
||||
|
||||
it("uses SkillHub only when source is 'skillhub'", async () => {
|
||||
const fetchCalls: string[] = []
|
||||
const service = createSkillMarketService({
|
||||
fetchImpl: async (url) => {
|
||||
fetchCalls.push(String(url))
|
||||
return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE)
|
||||
},
|
||||
installedSkillNames: async () => new Set(),
|
||||
})
|
||||
|
||||
const result = await service.listSkills({ source: 'skillhub', limit: 6, query: 'vetter', cursor: '2' })
|
||||
|
||||
expect(fetchCalls).toHaveLength(1)
|
||||
expect(fetchCalls[0]).toStartWith('https://api.skillhub.cn/api/skills')
|
||||
expect(fetchCalls[0]).toContain('sortBy=downloads')
|
||||
expect(fetchCalls[0]).toContain('order=desc')
|
||||
expect(fetchCalls[0]).toContain('limit=6')
|
||||
expect(fetchCalls[0]).toContain('query=vetter')
|
||||
expect(fetchCalls[0]).toContain('cursor=2')
|
||||
expect(result.source).toBe('skillhub')
|
||||
expect(result.items[0]).toMatchObject({
|
||||
source: 'skillhub',
|
||||
slug: 'skill-vetter',
|
||||
installed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported v1 sources instead of treating them as auto', async () => {
|
||||
const fetchCalls: string[] = []
|
||||
const service = createSkillMarketService({
|
||||
fetchImpl: async (url) => {
|
||||
fetchCalls.push(String(url))
|
||||
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
|
||||
},
|
||||
installedSkillNames: async () => new Set(),
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.listSkills({ source: 'anthropic-github' as 'auto' }),
|
||||
).rejects.toThrow('Unsupported skill market source')
|
||||
|
||||
expect(fetchCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
172
src/server/services/skillMarket/service.ts
Normal file
172
src/server/services/skillMarket/service.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { normalizeClawHubList } from './clawhubAdapter.js'
|
||||
import { normalizeSkillHubList } from './skillhubAdapter.js'
|
||||
import type { SkillMarketItem, SkillMarketListResult } from './types.js'
|
||||
|
||||
export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub'
|
||||
|
||||
export type SkillMarketListParams = {
|
||||
source?: SkillMarketListSource
|
||||
limit?: number
|
||||
query?: string
|
||||
cursor?: string
|
||||
sort?: 'downloads' | 'installs' | 'stars' | 'updated' | 'trending'
|
||||
}
|
||||
|
||||
type FetchImpl = typeof fetch
|
||||
type InstalledSkillNamesProvider = Set<string> | (() => Set<string> | Promise<Set<string>>)
|
||||
|
||||
export type SkillMarketServiceOptions = {
|
||||
fetchImpl?: FetchImpl
|
||||
installedSkillNames?: InstalledSkillNamesProvider
|
||||
}
|
||||
|
||||
export type SkillMarketService = {
|
||||
listSkills: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
|
||||
list: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
|
||||
}
|
||||
|
||||
const CLAWHUB_SKILLS_URL = 'https://clawhub.ai/api/v1/skills'
|
||||
const SKILLHUB_SKILLS_URL = 'https://api.skillhub.cn/api/skills'
|
||||
const DEFAULT_LIMIT = 24
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
export function createSkillMarketService(options: SkillMarketServiceOptions = {}): SkillMarketService {
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const installedSkillNames = options.installedSkillNames
|
||||
|
||||
async function listSkills(params: SkillMarketListParams = {}): Promise<SkillMarketListResult> {
|
||||
const source = params.source ?? 'auto'
|
||||
|
||||
if (source === 'clawhub') {
|
||||
return withInstalled(await listClawHub(params))
|
||||
}
|
||||
|
||||
if (source === 'skillhub') {
|
||||
return withInstalled(await listSkillHub(params, 'ok'))
|
||||
}
|
||||
|
||||
if (source !== 'auto') {
|
||||
throw new Error(`Unsupported skill market source: ${source}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return withInstalled(await listClawHub(params))
|
||||
} catch (error) {
|
||||
const fallback = await listSkillHub(params, 'fallback')
|
||||
return withInstalled({
|
||||
...fallback,
|
||||
sourceStatus: 'fallback',
|
||||
message: `ClawHub unavailable: ${errorMessage(error)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function listClawHub(params: SkillMarketListParams): Promise<SkillMarketListResult> {
|
||||
const url = new URL(CLAWHUB_SKILLS_URL)
|
||||
url.searchParams.set('sort', clawHubSort(params.sort))
|
||||
url.searchParams.set('nonSuspiciousOnly', 'true')
|
||||
url.searchParams.set('limit', String(limitFor(params.limit)))
|
||||
addOptionalParam(url, 'query', params.query)
|
||||
addOptionalParam(url, 'cursor', params.cursor)
|
||||
|
||||
const payload = await requestJson(fetchImpl, url, 'ClawHub')
|
||||
return normalizeClawHubList(payload)
|
||||
}
|
||||
|
||||
async function listSkillHub(
|
||||
params: SkillMarketListParams,
|
||||
sourceStatus: SkillMarketListResult['sourceStatus'],
|
||||
): Promise<SkillMarketListResult> {
|
||||
const url = new URL(SKILLHUB_SKILLS_URL)
|
||||
url.searchParams.set('sortBy', skillHubSort(params.sort))
|
||||
url.searchParams.set('order', 'desc')
|
||||
url.searchParams.set('limit', String(limitFor(params.limit)))
|
||||
addOptionalParam(url, 'query', params.query)
|
||||
addOptionalParam(url, 'cursor', params.cursor)
|
||||
|
||||
const payload = await requestJson(fetchImpl, url, 'SkillHub')
|
||||
return {
|
||||
...normalizeSkillHubList(payload),
|
||||
sourceStatus,
|
||||
}
|
||||
}
|
||||
|
||||
async function withInstalled(result: SkillMarketListResult): Promise<SkillMarketListResult> {
|
||||
const installed = await resolveInstalledSkillNames(installedSkillNames)
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item): SkillMarketItem => ({
|
||||
...item,
|
||||
installed: installed.has(item.slug),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listSkills,
|
||||
list: listSkills,
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(fetchImpl: FetchImpl, url: URL, sourceName: string): Promise<unknown> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(url)
|
||||
} catch (error) {
|
||||
throw new Error(`${sourceName} request failed: ${errorMessage(error)}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${sourceName} request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function resolveInstalledSkillNames(provider?: InstalledSkillNamesProvider): Promise<Set<string>> {
|
||||
if (!provider) {
|
||||
return new Set()
|
||||
}
|
||||
if (provider instanceof Set) {
|
||||
return provider
|
||||
}
|
||||
return provider()
|
||||
}
|
||||
|
||||
function limitFor(limit: number | undefined): number {
|
||||
if (!Number.isInteger(limit) || limit === undefined || limit < 1) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
return Math.min(limit, MAX_LIMIT)
|
||||
}
|
||||
|
||||
function clawHubSort(sort: SkillMarketListParams['sort']): string {
|
||||
if (sort === 'updated') {
|
||||
return 'updated'
|
||||
}
|
||||
if (sort === 'installs' || sort === 'stars' || sort === 'trending') {
|
||||
return sort
|
||||
}
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
function skillHubSort(sort: SkillMarketListParams['sort']): string {
|
||||
if (sort === 'updated') {
|
||||
return 'updated_at'
|
||||
}
|
||||
if (sort === 'installs' || sort === 'stars') {
|
||||
return sort
|
||||
}
|
||||
return 'downloads'
|
||||
}
|
||||
|
||||
function addOptionalParam(url: URL, name: string, value: string | undefined) {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed) {
|
||||
url.searchParams.set(name, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user