feat: unified query API + deploy metadata
- Add description/tags/examples to deploy params - Replace /_api/list with /_api/query (public, no auth) - find mode: precise, detailed, default limit=3 - explore mode: diverse, summary, default limit=20 - Relevance scoring + tag-based dedup for explore - Delete old list endpoint
This commit is contained in:
+30
-1
@@ -4,6 +4,9 @@ export interface DeployParams {
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number // 秒,仅 ephemeral
|
||||
bindings?: string[]
|
||||
description?: string // 一句话描述
|
||||
tags?: string[] // 标签
|
||||
examples?: string[] // 用法示例
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
@@ -23,6 +26,32 @@ export interface Capability {
|
||||
created_at: number
|
||||
ttl?: number
|
||||
expires_at?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
q?: string
|
||||
mode?: 'find' | 'explore'
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface QueryItem {
|
||||
capability: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
deployed?: boolean
|
||||
access_count?: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
total: number
|
||||
items: QueryItem[]
|
||||
}
|
||||
|
||||
export interface BackendStatus {
|
||||
@@ -37,7 +66,7 @@ export interface SigilBackend {
|
||||
deploy(params: DeployParams): Promise<DeployResult>
|
||||
invoke(name: string, request: Request): Promise<Response>
|
||||
remove(name: string): Promise<void>
|
||||
list(): Promise<Capability[]>
|
||||
query(params: QueryParams): Promise<QueryResult>
|
||||
inspect(name: string): Promise<Capability | null>
|
||||
status(): Promise<BackendStatus>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus } from './types.js'
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler, PageRateLimitError } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
import { scoreCapability, applyExploreDedup } from '../scoring.js'
|
||||
|
||||
export interface CfApi {
|
||||
deployWorker(name: string, code: string): Promise<void>
|
||||
@@ -40,7 +41,7 @@ export class WorkerPool implements SigilBackend {
|
||||
}
|
||||
|
||||
async deploy(params: DeployParams): Promise<DeployResult> {
|
||||
const { name, code, type, ttl, bindings } = params
|
||||
const { name, code, type, ttl, bindings, description, tags, examples } = params
|
||||
|
||||
// Determine capability name
|
||||
let capability: string
|
||||
@@ -90,6 +91,9 @@ export class WorkerPool implements SigilBackend {
|
||||
ttl,
|
||||
created_at: now,
|
||||
bindings,
|
||||
description,
|
||||
tags,
|
||||
examples,
|
||||
})
|
||||
await this.kv.setLru(capability, {
|
||||
last_access: now,
|
||||
@@ -284,9 +288,17 @@ export class WorkerPool implements SigilBackend {
|
||||
await this.kv.deleteRoute(capabilityName)
|
||||
}
|
||||
|
||||
async list(): Promise<Capability[]> {
|
||||
async query(params: QueryParams): Promise<QueryResult> {
|
||||
const { q, mode: rawMode, limit: rawLimit, cursor } = params
|
||||
|
||||
// Determine effective mode
|
||||
const mode = rawMode ?? (q ? 'find' : 'explore')
|
||||
const defaultLimit = mode === 'find' ? 3 : 20
|
||||
const limit = rawLimit ?? defaultLimit
|
||||
|
||||
// Fetch all capabilities
|
||||
const caps = await this.kv.listCapabilities()
|
||||
const result: Capability[] = []
|
||||
const allCapabilities: Capability[] = []
|
||||
|
||||
for (const cap of caps) {
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
@@ -300,6 +312,9 @@ export class WorkerPool implements SigilBackend {
|
||||
last_access: lru.last_access,
|
||||
access_count: lru.access_count,
|
||||
created_at: meta.created_at,
|
||||
description: meta.description,
|
||||
tags: meta.tags,
|
||||
examples: meta.examples,
|
||||
}
|
||||
|
||||
if (meta.ttl !== undefined) {
|
||||
@@ -307,10 +322,70 @@ export class WorkerPool implements SigilBackend {
|
||||
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
|
||||
}
|
||||
|
||||
result.push(capability)
|
||||
allCapabilities.push(capability)
|
||||
}
|
||||
|
||||
return result
|
||||
// If mode=find but no q → treat as explore
|
||||
const effectiveMode = (mode === 'find' && !q) ? 'explore' : mode
|
||||
|
||||
let items: QueryItem[]
|
||||
|
||||
if (!q) {
|
||||
// No query — explore mode: sort by created_at descending, return summaries
|
||||
const sorted = [...allCapabilities].sort((a, b) => b.created_at - a.created_at)
|
||||
items = sorted.map(cap => ({
|
||||
capability: cap.capability,
|
||||
description: cap.description,
|
||||
type: cap.type,
|
||||
score: 1.0,
|
||||
}))
|
||||
} else {
|
||||
// Score and filter
|
||||
const scored = allCapabilities
|
||||
.map(cap => ({ cap, score: scoreCapability(cap, q) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
if (effectiveMode === 'find') {
|
||||
items = scored.map(({ cap, score }) => ({
|
||||
capability: cap.capability,
|
||||
description: cap.description,
|
||||
tags: cap.tags,
|
||||
examples: cap.examples,
|
||||
type: cap.type,
|
||||
deployed: cap.deployed,
|
||||
access_count: cap.access_count,
|
||||
score,
|
||||
}))
|
||||
} else {
|
||||
// explore: build summary items then apply dedup
|
||||
const summaryItems: QueryItem[] = scored.map(({ cap, score }) => ({
|
||||
capability: cap.capability,
|
||||
description: cap.description,
|
||||
tags: cap.tags, // keep tags for dedup logic, stripped later
|
||||
type: cap.type,
|
||||
score,
|
||||
}))
|
||||
|
||||
const deduped = applyExploreDedup(summaryItems)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
// Strip tags/examples from explore output (only capability/description/type/score)
|
||||
items = deduped.map(({ capability, description, type, score }) => ({
|
||||
capability,
|
||||
description,
|
||||
type,
|
||||
score,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cursor (offset-based paging)
|
||||
const offset = cursor ? parseInt(cursor, 10) : 0
|
||||
const total = items.length
|
||||
const paged = items.slice(offset, offset + limit)
|
||||
|
||||
return { total, items: paged }
|
||||
}
|
||||
|
||||
async inspect(capabilityName: string): Promise<Capability | null> {
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface KvMetaValue {
|
||||
ttl?: number
|
||||
created_at: number
|
||||
bindings?: string[]
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
}
|
||||
|
||||
export interface KvLruValue {
|
||||
|
||||
+19
-15
@@ -30,9 +30,9 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
return handleRemove(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/list
|
||||
if (method === 'GET' && path === '/_api/list') {
|
||||
return handleList(request, env)
|
||||
// GET /_api/query — public, no auth
|
||||
if (method === 'GET' && path === '/_api/query') {
|
||||
return handleQuery(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/inspect/{capability}
|
||||
@@ -68,6 +68,9 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number
|
||||
bindings?: string[]
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
}
|
||||
|
||||
// Check deploy cooldown
|
||||
@@ -79,6 +82,9 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
|
||||
type: body.type,
|
||||
ttl: body.ttl,
|
||||
bindings: body.bindings,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
examples: body.examples,
|
||||
})
|
||||
|
||||
// Set cooldown after successful deploy
|
||||
@@ -114,19 +120,17 @@ async function handleRemove(request: Request, env: RouterEnv): Promise<Response>
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
await env.auth.validateToken(authHeader)
|
||||
async function handleQuery(request: Request, env: RouterEnv): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const q = url.searchParams.get('q') ?? undefined
|
||||
const modeRaw = url.searchParams.get('mode')
|
||||
const mode = (modeRaw === 'find' || modeRaw === 'explore') ? modeRaw : undefined
|
||||
const limitRaw = url.searchParams.get('limit')
|
||||
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined
|
||||
const cursor = url.searchParams.get('cursor') ?? undefined
|
||||
|
||||
const list = await env.backend.list()
|
||||
return jsonOk({ capabilities: list })
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const result = await env.backend.query({ q, mode, limit, cursor })
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleInspect(capability: string, env: RouterEnv): Promise<Response> {
|
||||
|
||||
+19
-11
@@ -5,7 +5,7 @@ import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S7: 列出能力', () => {
|
||||
describe('S7: 列出能力(已迁移至 query 接口)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
@@ -31,29 +31,37 @@ describe('S7: 列出能力', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should return all capabilities', async () => {
|
||||
it('/_api/list should return 404 (removed)', async () => {
|
||||
const req = makeRequest('GET', '/_api/list', {
|
||||
token: 'deploy-token',
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(404)
|
||||
})
|
||||
|
||||
it('/_api/query should return all capabilities (explore mode)', async () => {
|
||||
const req = makeRequest('GET', '/_api/query')
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
|
||||
const body = await resp.json() as { capabilities: Array<{ capability: string }> }
|
||||
expect(body.capabilities).toHaveLength(3)
|
||||
const body = await resp.json() as { total: number; items: Array<{ capability: string }> }
|
||||
expect(body.total).toBe(3)
|
||||
expect(body.items).toHaveLength(3)
|
||||
|
||||
const names = body.capabilities.map(c => c.capability)
|
||||
const names = body.items.map((c: { capability: string }) => c.capability)
|
||||
expect(names).toContain('ping')
|
||||
expect(names).toContain('echo')
|
||||
expect(names).toContain('hello')
|
||||
})
|
||||
|
||||
it('should include capability metadata in response', async () => {
|
||||
const caps = await pool.list()
|
||||
expect(caps.length).toBe(3)
|
||||
for (const cap of caps) {
|
||||
expect(cap.type).toBe('normal')
|
||||
expect(cap.deployed).toBe(true)
|
||||
it('should include capability metadata in query results', async () => {
|
||||
const result = await pool.query({})
|
||||
expect(result.total).toBe(3)
|
||||
for (const item of result.items) {
|
||||
expect(item.type).toBe('normal')
|
||||
expect(item.score).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user