mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-21 14:03:35 +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 { describe, expect, it } from 'bun:test'
|
||||||
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
|
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
|
||||||
|
import { createSkillMarketService } from '../services/skillMarket/service.js'
|
||||||
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
|
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
|
||||||
import {
|
import {
|
||||||
CLAWHUB_SCAN_RESPONSE,
|
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