v1.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. // 业务 API(docs/design/04-api.md)
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import type { FastifyInstance, FastifyRequest } from 'fastify'
  5. import type { CreateBrowserTokenBody, InteractionResponse, ProviderStatusDto, SessionDetailDto } from '@agent-server/shared'
  6. import { z } from 'zod'
  7. import type { App } from '../../app'
  8. import { AppError, badRequest, notFound } from '../../lib/errors'
  9. import { BROWSER_TOKEN_MAX_TTL } from '../../services/browser-tokens'
  10. import { parse } from '../../services/catalog'
  11. import { principal, requireClient } from '../auth'
  12. import { streamSessionEvents } from '../sse'
  13. import { forgetGatewaySession } from '../../mcp-gateway/server'
  14. type IdParams = { Params: { id: string } }
  15. const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
  16. z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
  17. z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
  18. z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
  19. ])
  20. export async function providerStatuses(app: App): Promise<ProviderStatusDto[]> {
  21. const settings = app.settings.get()
  22. const result: ProviderStatusDto[] = []
  23. for (const id of ['claude', 'codex'] as const) {
  24. const p = app.providers[id]
  25. if (p) {
  26. result.push(await p.status(settings))
  27. } else {
  28. const s = settings.providers[id]
  29. result.push({
  30. id, enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath,
  31. version: null, models: s.models, defaultModel: s.defaultModel, message: '尚未接入(M2)',
  32. })
  33. }
  34. }
  35. return result
  36. }
  37. /** 已注册的 /v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
  38. export const registeredV1Routes: { method: string; path: string }[] = []
  39. export default async function v1Routes(f: FastifyInstance, { app }: { app: App }) {
  40. f.addHook('onRoute', r => {
  41. for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredV1Routes.push({ method: m, path: r.url })
  42. })
  43. f.addHook('preHandler', requireClient(app))
  44. const sessionFor = (req: FastifyRequest<IdParams>) => app.sessions.getRow(req.params.id, principal(req).source)
  45. // 引擎状态缓存 30 秒,避免频繁调用 CLI
  46. let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
  47. // 业务平台的后端用 Agent Key 换取浏览器令牌(ADR-0019)
  48. f.post('/browser-tokens', async (req, reply) => {
  49. const p = principal(req)
  50. if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Agent Key 签发浏览器令牌')
  51. const body = (req.body ?? {}) as CreateBrowserTokenBody
  52. const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
  53. if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
  54. reply.code(201)
  55. return app.browserTokens.issue(p.source, ttl)
  56. })
  57. // 本平台(调用者自己的来源)的用量
  58. f.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30, source: principal(req).source }))
  59. f.get('/providers', async () => {
  60. if (!statusCache || Date.now() - statusCache.at > 30_000) statusCache = { at: Date.now(), data: await providerStatuses(app) }
  61. return statusCache.data
  62. })
  63. // 平台只能看到 Agent Key 上配置的 Profile;isDefault 表示该平台的默认 Profile
  64. f.get('/profiles', async req => {
  65. const p0 = principal(req)
  66. const acc = p0.kind === 'admin' ? null : app.apiKeys.access(p0.source)
  67. return app.profiles.list()
  68. .filter(p => !acc || acc.allowedProfileIds.includes(p.id))
  69. .map(p => ({
  70. id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode,
  71. isDefault: acc ? p.id === acc.defaultProfileId : p.isDefault,
  72. }))
  73. })
  74. // ---- 会话 ----
  75. f.post('/sessions', async (req, reply) => {
  76. const row = await app.sessions.create(principal(req).source, (req.body ?? {}) as object)
  77. reply.code(201)
  78. return app.sessions.toDto(row)
  79. })
  80. f.get<{ Querystring: { cursor?: string; limit?: string; scope?: string; archived?: string } }>('/sessions', async req => {
  81. const q = req.query
  82. return app.sessions.list(principal(req).source, {
  83. cursor: q.cursor,
  84. limit: q.limit ? Number(q.limit) : undefined,
  85. all: q.scope === 'all',
  86. archived: q.archived === undefined ? undefined : q.archived === 'true',
  87. })
  88. })
  89. f.get<IdParams>('/sessions/:id', async (req): Promise<SessionDetailDto> => {
  90. const row = sessionFor(req)
  91. const eff = app.sessions.effectiveProfile(row)
  92. return {
  93. ...app.sessions.toDto(row),
  94. activeRunId: app.runs.activeForSession(row.id)?.runId ?? null,
  95. pendingInteractions: app.interactions.pendingFor(row.id),
  96. permissionMode: eff.permissionMode,
  97. permissionModeOverridden: eff.overridden,
  98. systemPromptAppend: row.systemPromptAppend,
  99. }
  100. })
  101. f.patch<IdParams>('/sessions/:id', async req => app.sessions.toDto(app.sessions.patch(req.params.id, principal(req).source, req.body)))
  102. f.delete<IdParams>('/sessions/:id', async (req, reply) => {
  103. await app.sessions.delete(req.params.id, principal(req).source)
  104. app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id })
  105. app.backgroundCalls.cancelFor({ sessionId: req.params.id })
  106. app.gatewayTokens.forgetSession(req.params.id)
  107. forgetGatewaySession(req.params.id)
  108. reply.code(204)
  109. })
  110. // ---- 定时任务(docs/design/14-schedules.md):由 AI 在对话中创建,平台可以查看、暂停、恢复、删除 ----
  111. f.get<IdParams>('/sessions/:id/schedules', async req => app.schedules.list({ kind: 'session', sessionId: sessionFor(req).id }))
  112. const ownSchedule = (req: FastifyRequest<IdParams>) => app.schedules.getFor(req.params.id, t => {
  113. if (t.kind !== 'session') throw notFound('定时任务')
  114. app.sessions.getRow(t.sessionId, principal(req).source)
  115. })
  116. f.patch<IdParams>('/schedules/:id', async req => {
  117. const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
  118. return app.schedules.setStatus(ownSchedule(req).id, status)
  119. })
  120. f.delete<IdParams>('/schedules/:id', async (req, reply) => {
  121. app.schedules.delete(ownSchedule(req).id)
  122. reply.code(204)
  123. })
  124. f.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(ownSchedule(req).id))
  125. // ---- 后台调用(docs/design/15-background-calls.md):外部工具的长调用,平台可以查看和取消 ----
  126. f.get<IdParams>('/sessions/:id/background-calls', async req => app.backgroundCalls.list({ sessionId: sessionFor(req).id }))
  127. f.post<IdParams>('/background-calls/:id/cancel', async req => {
  128. const call = app.backgroundCalls.get(req.params.id)
  129. if (call.teamId) throw notFound('后台调用')
  130. app.sessions.getRow(call.sessionId, principal(req).source)
  131. return app.backgroundCalls.cancel(call.id)
  132. })
  133. f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/sessions/:id/events', async req => {
  134. const row = sessionFor(req)
  135. return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }
  136. })
  137. f.get<IdParams & { Querystring: { after?: string } }>('/sessions/:id/stream', async (req, reply) => {
  138. const row = sessionFor(req)
  139. const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0
  140. streamSessionEvents(app, req, reply, row.id, lastId)
  141. })
  142. // ---- 消息与 Run ----
  143. f.post<IdParams>('/sessions/:id/messages', async (req, reply) => {
  144. const row = sessionFor(req)
  145. reply.code(202)
  146. return app.runs.send(row, (req.body ?? {}) as { text: string })
  147. })
  148. f.get<IdParams>('/runs/:id', async req => {
  149. const run = app.runs.get(req.params.id)
  150. app.sessions.getRow(run.sessionId, principal(req).source)
  151. return run
  152. })
  153. f.post<IdParams>('/runs/:id/interrupt', async (req, reply) => {
  154. const run = app.runs.get(req.params.id)
  155. app.sessions.getRow(run.sessionId, principal(req).source)
  156. app.runs.interrupt(run.id, run.sessionId)
  157. reply.code(202)
  158. return { ok: true }
  159. })
  160. // ---- 交互 ----
  161. f.get<{ Querystring: { sessionId?: string } }>('/interactions', async req => {
  162. const sid = req.query.sessionId
  163. if (!sid) throw badRequest('需要 sessionId 参数')
  164. app.sessions.getRow(sid, principal(req).source)
  165. return app.interactions.pendingFor(sid)
  166. })
  167. f.post<IdParams>('/interactions/:id', async req => {
  168. const body = parse(interactionResponseSchema, req.body)
  169. const source = principal(req).source
  170. app.interactions.respond(req.params.id, body, sid => {
  171. try {
  172. app.sessions.getRow(sid, source)
  173. return true
  174. } catch {
  175. return false
  176. }
  177. })
  178. return { ok: true }
  179. })
  180. // ---- 文件 ----
  181. f.post<IdParams>('/sessions/:id/files', async (req, reply) => {
  182. const row = sessionFor(req)
  183. const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576
  184. const saved = []
  185. for await (const part of req.files({ limits: { fileSize: limitBytes, files: 20 } })) {
  186. saved.push(await app.files.saveUpload({
  187. sessionId: row.id, workspaceDir: row.workspaceDir, filename: part.filename, stream: part.file, limitBytes,
  188. truncated: () => part.file.truncated,
  189. }))
  190. }
  191. if (!saved.length) throw badRequest('没有收到文件')
  192. reply.code(201)
  193. return saved
  194. })
  195. f.get<IdParams>('/sessions/:id/files', async req => {
  196. const row = sessionFor(req)
  197. await app.files.scan(row.id, row.workspaceDir)
  198. return app.files.list(row.id)
  199. })
  200. f.get<IdParams & { Querystring: { inline?: string } }>('/files/:id', async (req, reply) => {
  201. const file = app.files.get(req.params.id)
  202. const row = app.sessions.getRow(file.sessionId, principal(req).source)
  203. const abs = path.join(row.workspaceDir, file.relPath)
  204. if (!fs.existsSync(abs)) throw notFound('文件')
  205. const name = encodeURIComponent(path.basename(file.relPath))
  206. reply.header('Content-Type', file.mime)
  207. reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${name}`)
  208. reply.header('X-Content-Type-Options', 'nosniff')
  209. // 预览 HTML/SVG 时禁止执行脚本
  210. reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
  211. return reply.send(fs.createReadStream(abs))
  212. })
  213. }