| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- // 业务 API(docs/design/04-api.md)
- import fs from 'node:fs'
- import path from 'node:path'
- import type { FastifyInstance, FastifyRequest } from 'fastify'
- import type { CreateBrowserTokenBody, InteractionResponse, ProviderStatusDto, SessionDetailDto } from '@agent-server/shared'
- import { z } from 'zod'
- import type { App } from '../../app'
- import { AppError, badRequest, notFound } from '../../lib/errors'
- import { BROWSER_TOKEN_MAX_TTL } from '../../services/browser-tokens'
- import { parse } from '../../services/catalog'
- import { principal, requireClient } from '../auth'
- import { streamSessionEvents } from '../sse'
- import { forgetGatewaySession } from '../../mcp-gateway/server'
- type IdParams = { Params: { id: string } }
- const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
- z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
- z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
- z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
- ])
- export async function providerStatuses(app: App): Promise<ProviderStatusDto[]> {
- const settings = app.settings.get()
- const result: ProviderStatusDto[] = []
- for (const id of ['claude', 'codex'] as const) {
- const p = app.providers[id]
- if (p) {
- result.push(await p.status(settings))
- } else {
- const s = settings.providers[id]
- result.push({
- id, enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath,
- version: null, models: s.models, defaultModel: s.defaultModel, message: '尚未接入(M2)',
- })
- }
- }
- return result
- }
- /** 已注册的 /v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
- export const registeredV1Routes: { method: string; path: string }[] = []
- export default async function v1Routes(f: FastifyInstance, { app }: { app: App }) {
- f.addHook('onRoute', r => {
- for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredV1Routes.push({ method: m, path: r.url })
- })
- f.addHook('preHandler', requireClient(app))
- const sessionFor = (req: FastifyRequest<IdParams>) => app.sessions.getRow(req.params.id, principal(req).source)
- // 引擎状态缓存 30 秒,避免频繁调用 CLI
- let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
- // 业务平台的后端用 Agent Key 换取浏览器令牌(ADR-0019)
- f.post('/browser-tokens', async (req, reply) => {
- const p = principal(req)
- if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Agent Key 签发浏览器令牌')
- const body = (req.body ?? {}) as CreateBrowserTokenBody
- const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
- if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
- reply.code(201)
- return app.browserTokens.issue(p.source, ttl)
- })
- // 本平台(调用者自己的来源)的用量
- f.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30, source: principal(req).source }))
- f.get('/providers', async () => {
- if (!statusCache || Date.now() - statusCache.at > 30_000) statusCache = { at: Date.now(), data: await providerStatuses(app) }
- return statusCache.data
- })
- // 平台只能看到 Agent Key 上配置的 Profile;isDefault 表示该平台的默认 Profile
- f.get('/profiles', async req => {
- const p0 = principal(req)
- const acc = p0.kind === 'admin' ? null : app.apiKeys.access(p0.source)
- return app.profiles.list()
- .filter(p => !acc || acc.allowedProfileIds.includes(p.id))
- .map(p => ({
- id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode,
- isDefault: acc ? p.id === acc.defaultProfileId : p.isDefault,
- }))
- })
- // ---- 会话 ----
- f.post('/sessions', async (req, reply) => {
- const row = await app.sessions.create(principal(req).source, (req.body ?? {}) as object)
- reply.code(201)
- return app.sessions.toDto(row)
- })
- f.get<{ Querystring: { cursor?: string; limit?: string; scope?: string; archived?: string } }>('/sessions', async req => {
- const q = req.query
- return app.sessions.list(principal(req).source, {
- cursor: q.cursor,
- limit: q.limit ? Number(q.limit) : undefined,
- all: q.scope === 'all',
- archived: q.archived === undefined ? undefined : q.archived === 'true',
- })
- })
- f.get<IdParams>('/sessions/:id', async (req): Promise<SessionDetailDto> => {
- const row = sessionFor(req)
- const eff = app.sessions.effectiveProfile(row)
- return {
- ...app.sessions.toDto(row),
- activeRunId: app.runs.activeForSession(row.id)?.runId ?? null,
- pendingInteractions: app.interactions.pendingFor(row.id),
- permissionMode: eff.permissionMode,
- permissionModeOverridden: eff.overridden,
- systemPromptAppend: row.systemPromptAppend,
- }
- })
- f.patch<IdParams>('/sessions/:id', async req => app.sessions.toDto(app.sessions.patch(req.params.id, principal(req).source, req.body)))
- f.delete<IdParams>('/sessions/:id', async (req, reply) => {
- await app.sessions.delete(req.params.id, principal(req).source)
- app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id })
- app.backgroundCalls.cancelFor({ sessionId: req.params.id })
- app.gatewayTokens.forgetSession(req.params.id)
- forgetGatewaySession(req.params.id)
- reply.code(204)
- })
- // ---- 定时任务(docs/design/14-schedules.md):由 AI 在对话中创建,平台可以查看、暂停、恢复、删除 ----
- f.get<IdParams>('/sessions/:id/schedules', async req => app.schedules.list({ kind: 'session', sessionId: sessionFor(req).id }))
- const ownSchedule = (req: FastifyRequest<IdParams>) => app.schedules.getFor(req.params.id, t => {
- if (t.kind !== 'session') throw notFound('定时任务')
- app.sessions.getRow(t.sessionId, principal(req).source)
- })
- f.patch<IdParams>('/schedules/:id', async req => {
- const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
- return app.schedules.setStatus(ownSchedule(req).id, status)
- })
- f.delete<IdParams>('/schedules/:id', async (req, reply) => {
- app.schedules.delete(ownSchedule(req).id)
- reply.code(204)
- })
- f.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(ownSchedule(req).id))
- // ---- 后台调用(docs/design/15-background-calls.md):外部工具的长调用,平台可以查看和取消 ----
- f.get<IdParams>('/sessions/:id/background-calls', async req => app.backgroundCalls.list({ sessionId: sessionFor(req).id }))
- f.post<IdParams>('/background-calls/:id/cancel', async req => {
- const call = app.backgroundCalls.get(req.params.id)
- if (call.teamId) throw notFound('后台调用')
- app.sessions.getRow(call.sessionId, principal(req).source)
- return app.backgroundCalls.cancel(call.id)
- })
- f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/sessions/:id/events', async req => {
- const row = sessionFor(req)
- return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }
- })
- f.get<IdParams & { Querystring: { after?: string } }>('/sessions/:id/stream', async (req, reply) => {
- const row = sessionFor(req)
- const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0
- streamSessionEvents(app, req, reply, row.id, lastId)
- })
- // ---- 消息与 Run ----
- f.post<IdParams>('/sessions/:id/messages', async (req, reply) => {
- const row = sessionFor(req)
- reply.code(202)
- return app.runs.send(row, (req.body ?? {}) as { text: string })
- })
- f.get<IdParams>('/runs/:id', async req => {
- const run = app.runs.get(req.params.id)
- app.sessions.getRow(run.sessionId, principal(req).source)
- return run
- })
- f.post<IdParams>('/runs/:id/interrupt', async (req, reply) => {
- const run = app.runs.get(req.params.id)
- app.sessions.getRow(run.sessionId, principal(req).source)
- app.runs.interrupt(run.id, run.sessionId)
- reply.code(202)
- return { ok: true }
- })
- // ---- 交互 ----
- f.get<{ Querystring: { sessionId?: string } }>('/interactions', async req => {
- const sid = req.query.sessionId
- if (!sid) throw badRequest('需要 sessionId 参数')
- app.sessions.getRow(sid, principal(req).source)
- return app.interactions.pendingFor(sid)
- })
- f.post<IdParams>('/interactions/:id', async req => {
- const body = parse(interactionResponseSchema, req.body)
- const source = principal(req).source
- app.interactions.respond(req.params.id, body, sid => {
- try {
- app.sessions.getRow(sid, source)
- return true
- } catch {
- return false
- }
- })
- return { ok: true }
- })
- // ---- 文件 ----
- f.post<IdParams>('/sessions/:id/files', async (req, reply) => {
- const row = sessionFor(req)
- const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576
- const saved = []
- for await (const part of req.files({ limits: { fileSize: limitBytes, files: 20 } })) {
- saved.push(await app.files.saveUpload({
- sessionId: row.id, workspaceDir: row.workspaceDir, filename: part.filename, stream: part.file, limitBytes,
- truncated: () => part.file.truncated,
- }))
- }
- if (!saved.length) throw badRequest('没有收到文件')
- reply.code(201)
- return saved
- })
- f.get<IdParams>('/sessions/:id/files', async req => {
- const row = sessionFor(req)
- await app.files.scan(row.id, row.workspaceDir)
- return app.files.list(row.id)
- })
- f.get<IdParams & { Querystring: { inline?: string } }>('/files/:id', async (req, reply) => {
- const file = app.files.get(req.params.id)
- const row = app.sessions.getRow(file.sessionId, principal(req).source)
- const abs = path.join(row.workspaceDir, file.relPath)
- if (!fs.existsSync(abs)) throw notFound('文件')
- const name = encodeURIComponent(path.basename(file.relPath))
- reply.header('Content-Type', file.mime)
- reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${name}`)
- reply.header('X-Content-Type-Options', 'nosniff')
- // 预览 HTML/SVG 时禁止执行脚本
- reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
- return reply.send(fs.createReadStream(abs))
- })
- }
|