feat: service discovery with scene merge and internalized pagination

This commit is contained in:
zeyu.fz
2026-08-15 18:37:34 +08:00
parent 95708ccf32
commit 5800222ea4
2 changed files with 108 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
/** Retrieval-service discovery: per-scene queries merged into one model-facing list; pagination stays internal. */
import type { ServiceListResponse } from './api-types.js'
import type { KbClient } from './client.js'
import { KB_PATHS } from './endpoints.js'
/** Server page-size maximum; one page per scene covers ordinary workspaces. */
const MAX_PAGE_SIZE = 100
export interface ServiceEntry {
agent_id: string
name: string
scene: string
status: string
knowledge_bases: string[]
}
export interface ServiceList {
services: ServiceEntry[]
total: number
/** True when some scene reported more rows than one max page returned. */
truncated: boolean
}
export interface ListServicesQuery {
scene?: 'chat' | 'search'
nameFilter?: string
}
/**
* List retrieval/Q&A services. An omitted scene fans out to both scenes and merges.
* @param client - the shared knowledge API client.
* @param query - optional scene and fuzzy name filter.
* @returns merged entries, the server-reported total, and the truncation flag.
*/
export async function listServices(client: KbClient, query: ListServicesQuery): Promise<ServiceList> {
const scenes: ('chat' | 'search')[] = query.scene ? [query.scene] : ['chat', 'search']
const services: ServiceEntry[] = []
let total = 0
for (const scene of scenes) {
const res = await client.postJson<ServiceListResponse>(KB_PATHS.serviceList, {
agent_scene: scene,
...(query.nameFilter ? { agent_name: query.nameFilter } : {}),
page_number: 1,
page_size: MAX_PAGE_SIZE,
})
total += res.data?.total_count ?? 0
for (const row of res.data?.rows ?? []) {
services.push({
agent_id: row.agent_id ?? '',
name: row.agent_name ?? '',
scene: row.agent_scene ?? scene,
status: row.agent_status ?? '',
knowledge_bases: (row.pipeline_list ?? []).map(p => p.pipeline_name ?? p.pipeline_id ?? '').filter(Boolean),
})
}
}
return { services, total, truncated: total > services.length }
}
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import type { ServiceListResponse } from '../src/api-types.js'
import type { KbClient } from '../src/client.js'
import { listServices } from '../src/services.js'
function fakeClient(byScene: Record<string, ServiceListResponse>) {
const postJson = vi.fn(async (_path: string, body: { agent_scene: string }) => byScene[body.agent_scene])
return { client: { postJson } as unknown as KbClient, postJson }
}
const row = (id: string, scene: string) => ({
agent_id: id, agent_name: `svc-${id}`, agent_scene: scene, agent_status: 'deployed',
pipeline_list: [{ pipeline_id: 'p1', pipeline_name: 'kb-one' }],
})
describe('listServices', () => {
it('queries both scenes when scene is omitted and merges rows with scene tags', async () => {
const { client, postJson } = fakeClient({
chat: { data: { total_count: 1, rows: [row('a', 'chat')] } },
search: { data: { total_count: 1, rows: [row('b', 'search')] } },
})
const out = await listServices(client, {})
expect(postJson).toHaveBeenCalledTimes(2)
expect(out.services.map(s => [s.agent_id, s.scene])).toEqual([['a', 'chat'], ['b', 'search']])
expect(out.services[0]!.knowledge_bases).toEqual(['kb-one'])
expect(out.total).toBe(2)
expect(out.truncated).toBe(false)
const body = postJson.mock.calls[0]![1] as unknown as Record<string, unknown>
expect(body.page_number).toBe(1)
expect(body.page_size).toBe(100)
})
it('queries one scene and forwards the name filter', async () => {
const { client, postJson } = fakeClient({ search: { data: { total_count: 0, rows: [] } } })
await listServices(client, { scene: 'search', nameFilter: '客服' })
expect(postJson).toHaveBeenCalledTimes(1)
expect((postJson.mock.calls[0]![1] as unknown as Record<string, unknown>).agent_name).toBe('客服')
})
it('flags truncation when a scene exceeds one max page', async () => {
const { client } = fakeClient({
chat: { data: { total_count: 250, rows: [row('a', 'chat')] } },
search: { data: { total_count: 0, rows: [] } },
})
const out = await listServices(client, {})
expect(out.truncated).toBe(true)
expect(out.total).toBe(250)
})
})