Răsfoiți Sursa

feat: 后台"API 协议"页面,支持下载 PDF

- 对外接口协议集中定义在 apps/server/src/api-reference/reference.ts(接口、参数、响应、错误、事件类型、数据结构、错误码),
  测试核对其中的接口与实际注册的 /v1 路由完全一致、事件类型与统一事件模型一致,避免文档与代码脱节
- 渲染为独立 HTML(无脚本、无外部资源,CSP 限制):后台页面内嵌显示,跟随亮/暗主题,示例使用浏览器看到的服务地址
- 下载 PDF:服务端用无头 Chrome 生成 A4 文档(已验证可在服务的 systemd 限制下运行,屏蔽网络、串行渲染并缓存);
  服务器没有 Chrome 时改用浏览器打印
- 修正 04-api.md 中已与代码不一致的内容(不存在的 preview 接口、tool.progress 事件等),并注明以 API 协议页面为准

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 săptămână în urmă
părinte
comite
9cbb287e29

+ 1 - 0
apps/admin/src/components/Workbench.vue

@@ -17,6 +17,7 @@ const nav = [
   { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
   { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },
   { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },
+  { to: '/api-docs', icon: 'codicon-book', label: 'API 协议' },
   { to: '/audit', icon: 'codicon-checklist', label: '审计日志' },
 ]
 

+ 1 - 0
apps/admin/src/lib/api.ts

@@ -30,6 +30,7 @@ export const admin = {
     logout: () => req<{ ok: true }>('POST', '/providers/codex/logout'),
   },
   providerQuota: (id: string) => req<ProviderQuotaDto>('GET', `/providers/${id}/quota`),
+  apiReferenceMeta: () => req<{ pdfAvailable: boolean }>('GET', '/api-reference/meta'),
   usage: (days = 30) => req<UsageSummaryDto>('GET', `/usage?days=${days}`),
   usageErrors: (limit = 10) => req<RecentErrorDto[]>('GET', `/usage/errors?limit=${limit}`),
   overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),

+ 1 - 0
apps/admin/src/router.ts

@@ -13,6 +13,7 @@ export const router = createRouter({
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
     { path: '/plugins', component: () => import('./views/PluginsView.vue') },
     { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },
+    { path: '/api-docs', component: () => import('./views/ApiDocsView.vue') },
     { path: '/audit', component: () => import('./views/AuditView.vue') },
     { path: '/settings', component: () => import('./views/SettingsView.vue') },
     { path: '/:pathMatch(.*)*', redirect: '/playground' },

+ 71 - 0
apps/admin/src/views/ApiDocsView.vue

@@ -0,0 +1,71 @@
+<script setup lang="ts">
+// 对外 API 协议:服务端按代码中的协议定义渲染(与实际接口一致,由测试保证),这里内嵌显示并提供 PDF 下载
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { toast } from '../lib/ui'
+
+const frame = ref<HTMLIFrameElement>()
+const pdfAvailable = ref(false)
+const downloading = ref(false)
+
+// 文档配色跟随后台当前的亮/暗主题
+const dark = ref(false)
+const media = window.matchMedia('(prefers-color-scheme: dark)')
+const updateTheme = () => {
+  const t = document.documentElement.getAttribute('data-theme')
+  dark.value = t ? t === 'dark' : media.matches
+}
+const observer = new MutationObserver(updateTheme)
+
+// 示例中的地址用浏览器看到的地址(经反向代理时与服务自身看到的不同)
+const base = location.origin
+const src = computed(() => `/admin/v1/api-reference.html?theme=${dark.value ? 'dark' : 'light'}&base=${encodeURIComponent(base)}`)
+
+async function downloadPdf() {
+  if (!pdfAvailable.value) {
+    // 服务器没有 Chrome 时改用浏览器打印(在打印对话框中选择"另存为 PDF")
+    frame.value?.contentWindow?.print()
+    return
+  }
+  downloading.value = true
+  try {
+    const res = await fetch(`/admin/v1/api-reference.pdf?base=${encodeURIComponent(base)}`, { credentials: 'same-origin' })
+    if (!res.ok) throw new Error((await res.json().catch(() => null))?.error?.message ?? `HTTP ${res.status}`)
+    const name = decodeURIComponent(/filename\*=UTF-8''([^;]+)/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'AgentServer-API.pdf')
+    const href = URL.createObjectURL(await res.blob())
+    const a = document.createElement('a')
+    a.href = href
+    a.download = name
+    a.click()
+    setTimeout(() => URL.revokeObjectURL(href), 10_000)
+  } catch (err) {
+    toast(`生成 PDF 失败:${errorMessage(err)}`, 'error')
+  } finally {
+    downloading.value = false
+  }
+}
+
+onMounted(async () => {
+  updateTheme()
+  media.addEventListener('change', updateTheme)
+  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
+  pdfAvailable.value = (await admin.apiReferenceMeta().catch(() => ({ pdfAvailable: false }))).pdfAvailable
+})
+onBeforeUnmount(() => {
+  media.removeEventListener('change', updateTheme)
+  observer.disconnect()
+})
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="API 协议" subtitle="业务平台调用的对外接口(/v1),内容由服务代码中的协议定义生成,与实际接口保持一致">
+      <a :href="src" target="_blank" rel="noopener" class="btn-ghost no-underline"><span class="codicon codicon-link-external" />新窗口打开</a>
+      <button type="button" class="btn-primary" :disabled="downloading" :title="pdfAvailable ? '' : '服务器没有安装 Chrome,将使用浏览器打印(选择另存为 PDF)'" @click="downloadPdf">
+        <span class="codicon" :class="downloading ? 'codicon-loading codicon-modifier-spin' : 'codicon-file-pdf'" />{{ downloading ? '生成中…' : '下载 PDF' }}
+      </button>
+    </PageHeader>
+    <iframe ref="frame" :src="src" title="API 协议" class="flex-1 w-full border-none bg-bg" />
+  </div>
+</template>

+ 58 - 0
apps/server/src/api-reference/pdf.ts

@@ -0,0 +1,58 @@
+// 用无头 Chrome 把 API 协议 HTML 打印为 PDF(中文字体、表格排版与浏览器一致)。
+// 已验证可在服务的 systemd 限制下运行(专用账号、ProtectSystem/ProtectHome、PrivateTmp)。
+// 只渲染本服务生成的 HTML(不含脚本和外部资源),并屏蔽网络;没有安装 Chrome 时后台改用浏览器打印。
+import { execFile } from 'node:child_process'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { promisify } from 'node:util'
+
+const execFileP = promisify(execFile)
+const CANDIDATES = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']
+
+export function findChrome(): string | null {
+  const configured = process.env.AGENT_SERVER_CHROME
+  return [configured, ...CANDIDATES].find((p): p is string => !!p && fs.existsSync(p)) ?? null
+}
+
+export class PdfRenderer {
+  private queue: Promise<unknown> = Promise.resolve()
+  private cache: { key: string; pdf: Buffer } | null = null
+
+  constructor(private chrome = findChrome()) {}
+
+  get available() {
+    return this.chrome !== null
+  }
+
+  /** 串行渲染;内容相同时直接返回上次的结果 */
+  render(html: string): Promise<Buffer> {
+    const key = crypto.createHash('sha256').update(html).digest('hex')
+    if (this.cache?.key === key) return Promise.resolve(this.cache.pdf)
+    const run = async () => {
+      if (!this.chrome) throw new Error('服务器上没有安装 Chrome / Chromium')
+      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'api-pdf-'))
+      try {
+        const src = path.join(dir, 'doc.html')
+        const out = path.join(dir, 'doc.pdf')
+        fs.writeFileSync(src, html)
+        await execFileP(this.chrome, [
+          '--headless=new', '--no-sandbox', '--disable-gpu', '--no-pdf-header-footer', '--no-first-run',
+          '--disable-extensions', '--disable-background-networking', '--disable-sync',
+          // 文档不引用外部资源;保险起见屏蔽所有域名解析
+          '--host-resolver-rules=MAP * ~NOTFOUND',
+          `--user-data-dir=${path.join(dir, 'profile')}`, `--print-to-pdf=${out}`, `file://${src}`,
+        ], { timeout: 60_000 })
+        const pdf = fs.readFileSync(out)
+        this.cache = { key, pdf }
+        return pdf
+      } finally {
+        fs.rmSync(dir, { recursive: true, force: true })
+      }
+    }
+    const p = this.queue.then(run, run)
+    this.queue = p.catch(() => {})
+    return p
+  }
+}

+ 391 - 0
apps/server/src/api-reference/reference.ts

@@ -0,0 +1,391 @@
+// 对外 API(/v1)协议参考:后台"API 协议"页面与 PDF 的唯一数据来源。
+// test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /v1 路由完全一致,新增或删除接口时必须同步修改。
+
+export interface Field {
+  name: string
+  type: string
+  required?: boolean
+  desc: string
+}
+
+export interface Endpoint {
+  method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
+  path: string
+  summary: string
+  desc?: string
+  query?: Field[]
+  body?: Field[]
+  /** 请求体不是 JSON 时的说明(如 multipart) */
+  bodyNote?: string
+  response: { status: number; type: string; desc?: string }
+  errors?: string[]
+  example?: string
+}
+
+export interface EndpointGroup {
+  id: string
+  title: string
+  desc?: string
+  endpoints: Endpoint[]
+}
+
+export interface TypeDef {
+  name: string
+  desc?: string
+  fields: Field[]
+}
+
+export interface EventDef {
+  type: string
+  payload: string
+  desc: string
+  persisted: boolean
+}
+
+export const API_GROUPS: EndpointGroup[] = [
+  {
+    id: 'auth',
+    title: '令牌',
+    desc: '业务平台的后端用 API Key 换取短期浏览器令牌,交给前端使用;API Key 不要出现在前端代码中。',
+    endpoints: [
+      {
+        method: 'POST', path: '/v1/browser-tokens', summary: '签发浏览器令牌',
+        desc: '只能使用 API Key 调用(浏览器令牌不能再签发令牌)。令牌的权限与签发它的 API Key 相同;停用或删除 API Key 后,它签发的全部令牌立即失效。',
+        body: [{ name: 'ttlSeconds', type: 'number', desc: '有效期(秒),60 ~ 43200,默认 3600' }],
+        response: { status: 201, type: 'BrowserToken' },
+        errors: ['400 bad_request:ttlSeconds 超出范围', '403 forbidden:使用浏览器令牌调用'],
+        example: `curl -X POST $BASE/v1/browser-tokens \\
+  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\
+  -d '{"ttlSeconds":3600}'
+# → 201 {"token":"bt_…","expiresAt":"2026-10-23T14:00:00.000Z"}`,
+      },
+    ],
+  },
+  {
+    id: 'catalog',
+    title: 'Profile 与引擎',
+    endpoints: [
+      {
+        method: 'GET', path: '/v1/profiles', summary: '本平台可用的 Profile',
+        desc: '只返回在后台为该 API Key 勾选的 Profile。isDefault 表示该平台的默认 Profile(创建会话不指定 Profile 时使用)。',
+        response: { status: 200, type: 'ProfileSummary[]' },
+      },
+      {
+        method: 'GET', path: '/v1/providers', summary: '引擎状态与可选模型',
+        desc: '结果缓存 30 秒。',
+        response: { status: 200, type: 'ProviderStatus[]' },
+      },
+    ],
+  },
+  {
+    id: 'sessions',
+    title: '会话',
+    desc: '一个会话对应一个独立的工作区和一段连续的对话。会话归属于创建它的 API Key,其他平台看不到。',
+    endpoints: [
+      {
+        method: 'POST', path: '/v1/sessions', summary: '创建会话',
+        desc: '引擎和权限模式由 Profile 决定,不能在这里指定;模型可以在 Profile 引擎的可选模型中自选。',
+        body: [
+          { name: 'profileId', type: 'string', desc: '使用的 Profile,必须是本平台可用的;省略时使用默认 Profile' },
+          { name: 'model', type: 'string', desc: '模型,省略时使用 Profile 的默认模型' },
+          { name: 'title', type: 'string', desc: '标题(≤ 200 字),省略时用第一条消息自动生成' },
+          { name: 'systemPromptAppend', type: 'string', desc: '追加在 Profile 系统提示词之后的会话级提示(≤ 20000 字)' },
+        ],
+        response: { status: 201, type: 'Session' },
+        errors: ['403 profile_not_allowed:Profile 不在本平台可用列表中', '400 bad_request:模型不可用、引擎未启用,或传入了 provider / permissionMode'],
+        example: `curl -X POST $BASE/v1/sessions -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/json" -d '{"title":"三季度销售分析"}'`,
+      },
+      {
+        method: 'GET', path: '/v1/sessions', summary: '会话列表',
+        desc: '按最近更新时间倒序,游标分页。',
+        query: [
+          { name: 'limit', type: 'number', desc: '每页数量,1 ~ 200,默认 50' },
+          { name: 'cursor', type: 'string', desc: '上一页返回的 nextCursor' },
+          { name: 'archived', type: '"true" | "false"', desc: '只看已归档 / 未归档;省略时全部' },
+        ],
+        response: { status: 200, type: 'Page<Session>' },
+      },
+      {
+        method: 'GET', path: '/v1/sessions/:id', summary: '会话详情',
+        desc: '包含正在运行的 Run 和未处理的交互,用于页面刷新后恢复状态。',
+        response: { status: 200, type: 'SessionDetail' },
+        errors: ['404 not_found', '403 profile_not_allowed:会话使用的 Profile 已不再对本平台开放'],
+      },
+      {
+        method: 'PATCH', path: '/v1/sessions/:id', summary: '修改会话',
+        body: [
+          { name: 'title', type: 'string', desc: '标题' },
+          { name: 'archived', type: 'boolean', desc: '归档 / 取消归档' },
+          { name: 'model', type: 'string', desc: '更换模型(空闲时)' },
+          { name: 'systemPromptAppend', type: 'string', desc: '会话级追加提示(空闲时)' },
+          { name: 'profileId', type: 'string', desc: '更换 Profile(只能在还没有对话时)' },
+        ],
+        response: { status: 200, type: 'Session' },
+        errors: ['409 session_busy:运行中不能修改模型、提示词或 Profile', '400 bad_request:已经开始对话后更换 Profile'],
+      },
+      {
+        method: 'DELETE', path: '/v1/sessions/:id', summary: '删除会话',
+        desc: '同时删除工作区中的全部文件,不可恢复。',
+        response: { status: 204, type: '—' },
+        errors: ['409 session_busy:运行中,请先中断'],
+      },
+    ],
+  },
+  {
+    id: 'messages',
+    title: '消息与运行',
+    desc: '发送消息会创建一个 Run(Agent 的一次执行)。同一会话同时只能有一个 Run;结果通过事件流推送。',
+    endpoints: [
+      {
+        method: 'POST', path: '/v1/sessions/:id/messages', summary: '发送消息',
+        body: [
+          { name: 'text', type: 'string', required: true, desc: '消息内容(1 ~ 100000 字)' },
+          { name: 'attachments', type: 'string[]', desc: '附件的 fileId(先通过上传接口上传);图片会直接交给模型识别' },
+        ],
+        response: { status: 202, type: 'Run', desc: 'Run 已排队,之后的进展通过事件流获得' },
+        errors: ['409 session_busy:上一个 Run 还没结束', '409 provider_unavailable:引擎当前不可用', '404 not_found:附件不存在'],
+        example: `curl -X POST $BASE/v1/sessions/$SID/messages -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/json" \\
+  -d '{"text":"分析附件中的销售数据并生成 Word 报告","attachments":["file_…"]}'`,
+      },
+      {
+        method: 'GET', path: '/v1/runs/:id', summary: 'Run 状态与用量',
+        response: { status: 200, type: 'Run' },
+      },
+      {
+        method: 'POST', path: '/v1/runs/:id/interrupt', summary: '中断 Run',
+        desc: '事件流中会出现 run.interrupted。',
+        response: { status: 202, type: '{ ok: true }' },
+      },
+    ],
+  },
+  {
+    id: 'events',
+    title: '事件',
+    desc: '会话中发生的一切(用户消息、Agent 文本、工具调用、产出文件、审批、用量)都以事件的形式按顺序记录,seq 在会话内单调递增。',
+    endpoints: [
+      {
+        method: 'GET', path: '/v1/sessions/:id/stream', summary: '订阅事件流(SSE)',
+        desc: '推荐每个打开的会话保持一条连接。先补发 seq 大于 Last-Event-ID 的历史事件,再推送实时事件;每 15 秒发送一次 ": ping" 保活。浏览器原生 EventSource 不能设置 Authorization 头,请使用 SDK 的 client.subscribe(fetch 实现,自动断线续传)。',
+        query: [{ name: 'after', type: 'number', desc: '从哪个 seq 之后开始(也可以用请求头 Last-Event-ID,优先)' }],
+        response: { status: 200, type: 'text/event-stream', desc: '每条事件:id: <seq>、event: <type>、data: <AgentEvent JSON>' },
+        example: `id: 12
+event: message.delta
+data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","ts":"…","payload":{"messageId":"msg_…","text":"好的"}}`,
+      },
+      {
+        method: 'GET', path: '/v1/sessions/:id/events', summary: '拉取历史事件',
+        desc: '用于打开会话时渲染完整时间线。高频的增量事件(message.delta、reasoning.delta)不落库,历史中只有对应的 completed 事件。',
+        query: [
+          { name: 'after', type: 'number', desc: '只返回 seq 大于该值的事件,默认 0' },
+          { name: 'limit', type: 'number', desc: '最多返回条数,≤ 5000' },
+        ],
+        response: { status: 200, type: '{ items: AgentEvent[]; lastSeq: number }' },
+      },
+    ],
+  },
+  {
+    id: 'interactions',
+    title: '交互(审批与提问)',
+    desc: 'Agent 需要执行有风险的操作(命令、写文件)或需要用户做选择时,事件流中出现 interaction.required,Run 进入 waiting_input;回复后 Agent 继续。超时(默认 10 分钟)按拒绝处理。',
+    endpoints: [
+      {
+        method: 'GET', path: '/v1/interactions', summary: '未处理的交互',
+        query: [{ name: 'sessionId', type: 'string', required: true, desc: '会话 ID' }],
+        response: { status: 200, type: 'InteractionRequest[]' },
+      },
+      {
+        method: 'POST', path: '/v1/interactions/:id', summary: '回复交互',
+        desc: '审批回复 decision;提问回复 answers(键为问题 id,多选时为数组)。拒绝时的 reason 会转告 Agent。',
+        bodyNote: 'InteractionResponse(三选一):{ decision: "allow_once" | "allow_session" } | { decision: "deny", reason? } | { answers: { [questionId]: string | string[] } }',
+        response: { status: 200, type: '{ ok: true }' },
+        errors: ['409 already_resolved:已被回复、超时或取消', '404 not_found'],
+        example: `curl -X POST $BASE/v1/interactions/$ITX -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/json" -d '{"decision":"allow_once"}'`,
+      },
+    ],
+  },
+  {
+    id: 'files',
+    title: '文件',
+    desc: '上传的文件保存在会话工作区的 uploads/,Agent 的产出一般在 outputs/。Agent 新建或修改文件时事件流中出现 file.changed。',
+    endpoints: [
+      {
+        method: 'POST', path: '/v1/sessions/:id/files', summary: '上传文件',
+        bodyNote: 'multipart/form-data,每个文件一个 file 字段;一次最多 20 个,单个文件不超过后台设置的上限(默认 50 MB)。同名文件自动改名。',
+        response: { status: 201, type: 'File[]' },
+        errors: ['413 file_too_large', '400 bad_request:没有收到文件'],
+        example: `curl -X POST $BASE/v1/sessions/$SID/files -H "Authorization: Bearer $TOKEN" \\
+  -F "file=@销售.xlsx" -F "file=@截图.png"`,
+      },
+      {
+        method: 'GET', path: '/v1/sessions/:id/files', summary: '工作区文件列表',
+        desc: '包括上传的文件和 Agent 产出的文件。',
+        response: { status: 200, type: 'File[]' },
+      },
+      {
+        method: 'GET', path: '/v1/files/:id', summary: '下载文件',
+        desc: '默认以附件下载;inline=1 用于在页面中预览(HTML / SVG 预览时禁止执行脚本)。',
+        query: [{ name: 'inline', type: '"1"', desc: '内联预览' }],
+        response: { status: 200, type: '文件内容', desc: 'Content-Type 为文件类型,Content-Disposition 带 UTF-8 文件名' },
+      },
+    ],
+  },
+  {
+    id: 'usage',
+    title: '用量',
+    endpoints: [
+      {
+        method: 'GET', path: '/v1/usage', summary: '本平台的用量',
+        desc: '只统计调用者所属 API Key 的会话;删除会话不影响历史用量。日期为服务器本地日期。',
+        query: [{ name: 'days', type: 'number', desc: '最近几天(含今天),1 ~ 366,默认 30' }],
+        response: { status: 200, type: 'UsageSummary' },
+      },
+    ],
+  },
+]
+
+export const API_TYPES: TypeDef[] = [
+  {
+    name: 'BrowserToken', fields: [
+      { name: 'token', type: 'string', desc: '以 bt_ 开头,放在 Authorization: Bearer 中使用' },
+      { name: 'expiresAt', type: 'string', desc: '过期时间(ISO 8601)' },
+    ],
+  },
+  {
+    name: 'ProfileSummary', fields: [
+      { name: 'id', type: 'string', desc: 'prf_ 开头' },
+      { name: 'name / description', type: 'string', desc: '名称与说明' },
+      { name: 'provider', type: '"claude" | "codex"', desc: '引擎' },
+      { name: 'model', type: 'string', desc: '默认模型' },
+      { name: 'permissionMode', type: '"ask" | "auto" | "readonly"', desc: '需审批 / 自动 / 只读' },
+      { name: 'isDefault', type: 'boolean', desc: '是否为本平台的默认 Profile' },
+    ],
+  },
+  {
+    name: 'ProviderStatus', fields: [
+      { name: 'id', type: '"claude" | "codex"', desc: '' },
+      { name: 'enabled / available', type: 'boolean', desc: '已启用 / 当前可用(已登录)' },
+      { name: 'models', type: 'string[]', desc: '可选模型' },
+      { name: 'defaultModel', type: 'string', desc: '默认模型' },
+      { name: 'message', type: 'string | null', desc: '不可用的原因' },
+    ],
+  },
+  {
+    name: 'Session', fields: [
+      { name: 'id', type: 'string', desc: 'ses_ 开头' },
+      { name: 'title', type: 'string', desc: '' },
+      { name: 'provider / model', type: 'string', desc: '引擎与模型' },
+      { name: 'profileId', type: 'string | null', desc: '' },
+      { name: 'status', type: '"idle" | "running" | "waiting_input"', desc: '空闲 / 运行中 / 等待回复交互' },
+      { name: 'archived', type: 'boolean', desc: '' },
+      { name: 'lastSeq', type: 'number', desc: '最后一个事件的 seq' },
+      { name: 'createdAt / updatedAt', type: 'string', desc: 'ISO 8601' },
+    ],
+  },
+  {
+    name: 'SessionDetail', desc: 'Session 的全部字段,另外包括:', fields: [
+      { name: 'activeRunId', type: 'string | null', desc: '正在运行的 Run' },
+      { name: 'pendingInteractions', type: 'InteractionRequest[]', desc: '未处理的交互' },
+      { name: 'permissionMode', type: 'string', desc: '实际生效的权限模式' },
+      { name: 'systemPromptAppend', type: 'string', desc: '会话级追加提示' },
+    ],
+  },
+  {
+    name: 'Page<T>', fields: [
+      { name: 'items', type: 'T[]', desc: '' },
+      { name: 'nextCursor', type: 'string | null', desc: '下一页的游标,null 表示没有更多' },
+    ],
+  },
+  {
+    name: 'Run', fields: [
+      { name: 'id', type: 'string', desc: 'run_ 开头' },
+      { name: 'sessionId', type: 'string', desc: '' },
+      { name: 'status', type: '"queued" | "running" | "waiting_input" | "completed" | "failed" | "interrupted"', desc: '' },
+      { name: 'startedAt / endedAt', type: 'string | null', desc: '' },
+      { name: 'usage', type: 'Usage | null', desc: '本次 Run 的 token 用量' },
+      { name: 'error', type: 'string | null', desc: '失败原因' },
+    ],
+  },
+  {
+    name: 'File', fields: [
+      { name: 'id', type: 'string', desc: 'file_ 开头' },
+      { name: 'path', type: 'string', desc: '工作区中的相对路径,如 uploads/销售.xlsx、outputs/报告.docx' },
+      { name: 'origin', type: '"upload" | "agent"', desc: '用户上传 / Agent 产出' },
+      { name: 'mime / size', type: 'string / number', desc: '类型与字节数' },
+      { name: 'createdAt / updatedAt', type: 'string', desc: '' },
+    ],
+  },
+  {
+    name: 'Usage', fields: [
+      { name: 'inputTokens / outputTokens', type: 'number', desc: '' },
+      { name: 'cacheReadTokens / cacheWriteTokens', type: 'number', desc: '' },
+      { name: 'costUsd', type: 'number?', desc: '估算费用(订阅登录下不是实际扣费)' },
+    ],
+  },
+  {
+    name: 'UsageSummary', fields: [
+      { name: 'from / to', type: 'string', desc: '统计区间(YYYY-MM-DD)' },
+      { name: 'total / today', type: 'UsageTotals', desc: '区间合计 / 今天' },
+      { name: 'daily', type: '({ date } & UsageTotals)[]', desc: '每天一项,没有用量的日期也会列出' },
+      { name: 'byModel / byProvider', type: 'array', desc: '按模型 / 按引擎汇总' },
+      { name: 'UsageTotals', type: 'object', desc: 'runs、failed、interrupted、inputTokens、outputTokens、cacheReadTokens、cacheWriteTokens、costUsd、durationMs' },
+    ],
+  },
+  {
+    name: 'AgentEvent', desc: '所有事件的信封', fields: [
+      { name: 'seq', type: 'number', desc: '会话内单调递增,即 SSE 的 id' },
+      { name: 'type', type: 'string', desc: '事件类型,见"事件类型"' },
+      { name: 'sessionId', type: 'string', desc: '' },
+      { name: 'runId', type: 'string | null', desc: '所属 Run' },
+      { name: 'ts', type: 'string', desc: 'ISO 8601' },
+      { name: 'payload', type: 'object', desc: '随类型不同' },
+    ],
+  },
+  {
+    name: 'InteractionRequest', desc: 'kind 为 approval(审批)或 question(提问)', fields: [
+      { name: 'interactionId', type: 'string', desc: 'itx_ 开头' },
+      { name: 'kind', type: '"approval" | "question"', desc: '' },
+      { name: 'expiresAt', type: 'string', desc: '超时时间,超时按拒绝处理' },
+      { name: 'tool / title / input', type: 'approval', desc: '要执行的工具、可读摘要、原始参数' },
+      { name: 'detail', type: 'approval', desc: '{ command?, path?, url?, reason? }' },
+      { name: 'options', type: 'approval', desc: '可选的决定:allow_once(允许一次)、allow_session(本会话内允许)、deny' },
+      { name: 'questions', type: 'question', desc: '[{ id, question, header?, multiSelect, options: [{ label, description? }] }]' },
+    ],
+  },
+]
+
+export const API_EVENTS: EventDef[] = [
+  { type: 'user.message', payload: '{ text, attachments: [{ fileId, name, mime, size }] }', desc: '用户发送的消息', persisted: true },
+  { type: 'run.queued', payload: '{ position }', desc: 'Run 排队中', persisted: true },
+  { type: 'run.started', payload: '{ provider, model }', desc: 'Run 开始执行', persisted: true },
+  { type: 'message.delta', payload: '{ messageId, text }', desc: 'Agent 文本增量(流式)', persisted: false },
+  { type: 'message.completed', payload: '{ messageId, text }', desc: 'Agent 一段完整的文本', persisted: true },
+  { type: 'reasoning.delta', payload: '{ blockId, text }', desc: '思考过程增量(建议默认折叠)', persisted: false },
+  { type: 'reasoning.completed', payload: '{ blockId, text }', desc: '一段完整的思考过程', persisted: true },
+  { type: 'tool.started', payload: '{ toolCallId, name, title, input }', desc: '开始调用工具;title 是可读摘要,如"读取 销售.xlsx"', persisted: true },
+  { type: 'tool.completed', payload: '{ toolCallId, status: "ok" | "error" | "denied", output, truncated }', desc: '工具调用结束', persisted: true },
+  { type: 'file.changed', payload: '{ fileId, path, kind: "created" | "modified", size, mime }', desc: '工作区中新建或修改了文件(Agent 产出)', persisted: true },
+  { type: 'interaction.required', payload: 'InteractionRequest', desc: '需要用户审批或回答问题', persisted: true },
+  { type: 'interaction.resolved', payload: '{ interactionId, resolution, answers?, reason? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled。多个页面同时打开时用于同步关闭卡片', persisted: true },
+  { type: 'usage', payload: 'Usage', desc: '本次 Run 的 token 用量', persisted: true },
+  { type: 'run.completed', payload: '{ durationMs }', desc: 'Run 正常结束', persisted: true },
+  { type: 'run.failed', payload: '{ code, message }', desc: 'Run 失败', persisted: true },
+  { type: 'run.interrupted', payload: '{ reason: "user" | "restart" | "timeout" }', desc: 'Run 被中断', persisted: true },
+]
+
+export const API_ERRORS: { status: number; code: string; desc: string }[] = [
+  { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
+  { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },
+  { status: 401, code: 'invalid_api_key', desc: 'API Key 无效或已停用' },
+  { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期,或签发它的 API Key 已停用(SDK 会自动刷新一次)' },
+  { status: 403, code: 'forbidden', desc: '无权执行该操作' },
+  { status: 403, code: 'profile_not_allowed', desc: 'Profile 不对本平台开放' },
+  { status: 404, code: 'not_found', desc: '资源不存在,或不属于本平台' },
+  { status: 409, code: 'session_busy', desc: '会话正在运行' },
+  { status: 409, code: 'provider_unavailable', desc: '引擎当前不可用(未登录或已停用)' },
+  { status: 409, code: 'already_resolved', desc: '交互已被回复、超时或取消' },
+  { status: 413, code: 'file_too_large', desc: '上传文件超过大小上限' },
+  { status: 500, code: 'internal_error', desc: '服务器内部错误' },
+]

+ 152 - 0
apps/server/src/api-reference/render.ts

@@ -0,0 +1,152 @@
+// 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):后台页面内嵌显示,也用于生成 PDF
+import { API_ERRORS, API_EVENTS, API_GROUPS, API_TYPES, type Endpoint, type Field } from './reference'
+
+export interface RenderOptions {
+  /** 文档中示例使用的服务地址 */
+  baseUrl: string
+  theme: 'light' | 'dark'
+  version: string
+  generatedAt: Date
+}
+
+const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
+/** 行内代码:`xxx` → <code> */
+const inline = (s: string) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>')
+/** "409 session_busy:说明" → 状态码加粗、错误码为代码样式 */
+const errorItem = (x: string) => {
+  const m = /^(\d{3}) ([a-z_]+)(.*)$/.exec(x)
+  return m ? `<li><b>${m[1]}</b> <code>${m[2]}</code>${inline(m[3]!)}</li>` : `<li>${inline(x)}</li>`
+}
+const anchor = (e: Endpoint) => `${e.method}-${e.path}`.toLowerCase().replace(/[^a-z0-9]+/g, '-')
+
+function fieldTable(fields: Field[], withRequired = true) {
+  return `<table class="fields"><thead><tr><th>字段</th><th>类型</th>${withRequired ? '<th>必填</th>' : ''}<th>说明</th></tr></thead><tbody>${
+    fields.map(f => `<tr><td><code>${esc(f.name)}</code></td><td><code class="type">${esc(f.type)}</code></td>${withRequired ? `<td>${f.required ? '是' : ''}</td>` : ''}<td>${inline(f.desc)}</td></tr>`).join('')
+  }</tbody></table>`
+}
+
+function endpoint(e: Endpoint, base: string) {
+  return `<section class="endpoint" id="${anchor(e)}">
+  <h3><span class="method ${e.method.toLowerCase()}">${e.method}</span><code class="path">${esc(e.path)}</code><span class="summary">${esc(e.summary)}</span></h3>
+  ${e.desc ? `<p>${inline(e.desc)}</p>` : ''}
+  ${e.query?.length ? `<h4>查询参数</h4>${fieldTable(e.query)}` : ''}
+  ${e.body?.length ? `<h4>请求体(JSON)</h4>${fieldTable(e.body)}` : ''}
+  ${e.bodyNote ? `<h4>请求体</h4><p>${inline(e.bodyNote)}</p>` : ''}
+  <h4>响应</h4><p><span class="status">${e.response.status}</span> <code class="type">${esc(e.response.type)}</code>${e.response.desc ? ` — ${inline(e.response.desc)}` : ''}</p>
+  ${e.errors?.length ? `<h4>常见错误</h4><ul>${e.errors.map(errorItem).join('')}</ul>` : ''}
+  ${e.example ? `<h4>示例</h4><pre>${esc(e.example.replaceAll('$BASE', base))}</pre>` : ''}
+</section>`
+}
+
+export function renderApiReferenceHtml(o: RenderOptions): string {
+  const base = o.baseUrl.replace(/\/+$/, '')
+  const date = o.generatedAt.toISOString().slice(0, 10)
+  const toc = [
+    ['overview', '1. 概述'],
+    ...API_GROUPS.map((g, i) => [g.id, `${i + 2}. ${g.title}`]),
+    ['event-types', `${API_GROUPS.length + 2}. 事件类型`],
+    ['types', `${API_GROUPS.length + 3}. 数据结构`],
+    ['errors', `${API_GROUPS.length + 4}. 错误码`],
+  ]
+  return `<!doctype html>
+<html lang="zh-CN" data-theme="${o.theme}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>AgentServer API 协议 ${esc(o.version)}</title>
+<style>
+:root { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --code-bg:#f6f8fa; --link:#0969da;
+  --get:#1a7f37; --post:#0969da; --patch:#9a6700; --delete:#cf222e; }
+[data-theme="dark"] { --bg:#1f1f1f; --fg:#cccccc; --muted:#9d9d9d; --border:#3c3c3c; --code-bg:#2b2b2b; --link:#4daafc;
+  --get:#3fb950; --post:#58a6ff; --patch:#d29922; --delete:#f85149; }
+* { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg); color: var(--fg); }
+body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
+  font-size: 13.5px; line-height: 1.65; padding: 32px 40px 64px; max-width: 980px; margin: 0 auto; }
+h1 { font-size: 26px; margin: 0 0 4px; }
+h2 { font-size: 19px; margin: 36px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
+h3 { font-size: 15px; margin: 0 0 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+h4 { font-size: 12.5px; margin: 14px 0 6px; color: var(--muted); }
+p, ul { margin: 6px 0; }
+a { color: var(--link); text-decoration: none; }
+code, pre { font-family: "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace; font-size: 12px; }
+code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
+code.type { color: var(--muted); }
+pre { background: var(--code-bg); padding: 10px 12px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; margin: 6px 0; }
+.meta { color: var(--muted); margin-bottom: 20px; }
+.toc { columns: 2; padding-left: 18px; margin: 8px 0 0; }
+table { border-collapse: collapse; width: 100%; margin: 6px 0; }
+th, td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; vertical-align: top; }
+th { background: var(--code-bg); font-weight: 600; white-space: nowrap; }
+td code { white-space: nowrap; }
+td code.type { white-space: normal; }
+.endpoint { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin: 14px 0; }
+.method { font: 700 11.5px/1 sans-serif; color: #fff; padding: 4px 7px; border-radius: 4px; }
+.method.get { background: var(--get); } .method.post { background: var(--post); } .method.patch { background: var(--patch); } .method.delete { background: var(--delete); }
+code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
+.summary { color: var(--muted); font-weight: 500; }
+.status { font-weight: 700; }
+.group-desc { color: var(--muted); }
+.no { color: var(--muted); }
+@page { size: A4; margin: 14mm 12mm; }
+@media print {
+  :root, [data-theme="dark"] { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --code-bg:#f6f8fa; --link:#0969da;
+    --get:#1a7f37; --post:#0969da; --patch:#9a6700; --delete:#cf222e; }
+  body { padding: 0; max-width: none; font-size: 11.5px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+  .endpoint, table, pre { break-inside: avoid; }
+  h2 { break-after: avoid; }
+}
+</style>
+</head>
+<body>
+<h1>AgentServer 对外 API 协议</h1>
+<div class="meta">版本 ${esc(o.version)} · 服务地址 <code>${esc(base)}</code> · 生成于 ${date}</div>
+
+<h2>目录</h2>
+<ol class="toc">${toc.map(([id, t]) => `<li><a href="#${id}">${esc(t!.replace(/^\d+\. /, ''))}</a></li>`).join('')}</ol>
+
+<h2 id="overview">1. 概述</h2>
+<ul>
+  <li><b>基础地址</b>:<code>${esc(base)}</code>,所有接口位于 <code>/v1</code> 下;请求和响应为 JSON(UTF-8),时间为 ISO 8601。</li>
+  <li><b>鉴权</b>:<code>Authorization: Bearer &lt;凭证&gt;</code>。
+    <ul>
+      <li><b>API Key</b>(<code>ask_…</code>):每个业务平台一个,在后台签发,<b>只在平台后端使用</b>。</li>
+      <li><b>浏览器令牌</b>(<code>bt_…</code>):平台后端调用 <code>POST /v1/browser-tokens</code> 换取,交给浏览器使用,默认 1 小时有效。</li>
+    </ul>
+  </li>
+  <li><b>可见范围</b>:每个 API Key 只能使用后台为它勾选的 Profile,只能看到自己创建的会话。</li>
+  <li><b>ID 前缀</b>:会话 <code>ses_</code>、Run <code>run_</code>、交互 <code>itx_</code>、文件 <code>file_</code>、Profile <code>prf_</code>。</li>
+  <li><b>错误格式</b>:<code>{ "error": { "code": "session_busy", "message": "…", "details": {} } }</code>,错误码见第 ${API_GROUPS.length + 4} 节。</li>
+  <li><b>跨域</b>:浏览器直接调用时,需在后台"设置 › 安全 › CORS 来源"中加入平台地址;请求不携带 Cookie。</li>
+  <li><b>SDK</b>:TypeScript 客户端与 Vue 对话组件,安装地址见 <code>${esc(base)}/sdk/index.json</code>,用法见接入指南(docs/integration.md)。</li>
+</ul>
+<h4>典型流程</h4>
+<ol>
+  <li>平台后端换取浏览器令牌 → 前端 <code>POST /v1/sessions</code> 创建会话;</li>
+  <li>前端订阅 <code>GET /v1/sessions/:id/stream</code>;需要附件时先 <code>POST /v1/sessions/:id/files</code> 上传;</li>
+  <li><code>POST /v1/sessions/:id/messages</code> 发送消息,事件流中依次出现文本、工具调用、产出文件;</li>
+  <li>出现 <code>interaction.required</code> 时由用户决定,调用 <code>POST /v1/interactions/:id</code> 回复;</li>
+  <li>收到 <code>run.completed</code> 后,用 <code>GET /v1/files/:id</code> 下载产出文件。</li>
+</ol>
+
+${API_GROUPS.map((g, i) => `<h2 id="${g.id}">${i + 2}. ${esc(g.title)}</h2>
+${g.desc ? `<p class="group-desc">${inline(g.desc)}</p>` : ''}
+${g.endpoints.map(e => endpoint(e, base)).join('\n')}`).join('\n')}
+
+<h2 id="event-types">${API_GROUPS.length + 2}. 事件类型</h2>
+<p>事件信封见"数据结构 › AgentEvent"。"落库"为否的事件只在实时事件流中推送,历史事件中只保留对应的 completed 事件。</p>
+<table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
+${API_EVENTS.map(e => `<tr><td><code>${esc(e.type)}</code></td><td><code class="type">${esc(e.payload)}</code></td><td>${inline(e.desc)}</td><td>${e.persisted ? '是' : '<span class="no">否</span>'}</td></tr>`).join('\n')}
+</tbody></table>
+
+<h2 id="types">${API_GROUPS.length + 3}. 数据结构</h2>
+${API_TYPES.map(t => `<h3 id="type-${t.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}"><code class="path">${esc(t.name)}</code></h3>
+${t.desc ? `<p class="group-desc">${inline(t.desc)}</p>` : ''}${fieldTable(t.fields, false)}`).join('\n')}
+
+<h2 id="errors">${API_GROUPS.length + 4}. 错误码</h2>
+<table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead><tbody>
+${API_ERRORS.map(e => `<tr><td>${e.status}</td><td><code>${esc(e.code)}</code></td><td>${inline(e.desc)}</td></tr>`).join('\n')}
+</tbody></table>
+</body>
+</html>`
+}

+ 3 - 1
apps/server/src/app.ts

@@ -20,6 +20,7 @@ import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
 import { UsageService } from './services/usage'
+import { PdfRenderer } from './api-reference/pdf'
 import { BrowserTokenService } from './services/browser-tokens'
 
 export interface AppLogger {
@@ -42,6 +43,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const audit = new AuditService(db)
   const usage = new UsageService(db)
   const browserTokens = new BrowserTokenService(db)
+  const apiPdf = new PdfRenderer()
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -58,7 +60,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 3 - 0
apps/server/src/config.ts

@@ -34,6 +34,9 @@ export const config = {
 /** 引擎访问 MCP Gateway 的地址(引擎与服务在同一台机器上) */
 export const gatewayUrl = () => `http://127.0.0.1:${config.port}/mcp`
 
+/** 服务版本(概览页与 API 协议文档中显示) */
+export const APP_VERSION = '0.1.0'
+
 export const dbPath = () => path.join(config.dataDir, 'data', 'agent.db')
 
 /** 第三方技能目录(Claude 插件格式,两个引擎共用,ADR-0017) */

+ 32 - 2
apps/server/src/http/routes/admin.ts

@@ -1,5 +1,5 @@
 // 管理 API:仅后台登录态可访问
-import type { FastifyInstance } from 'fastify'
+import type { FastifyInstance, FastifyRequest } from 'fastify'
 import { z } from 'zod'
 import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
@@ -11,6 +11,15 @@ import { parse, type AuditKind, type KnowledgeInput } from '../../services/catal
 import { defaultSettings } from '../../services/settings'
 import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
 import { providerStatuses } from './v1'
+import { renderApiReferenceHtml } from '../../api-reference/render'
+import { APP_VERSION } from '../../config'
+
+/** 文档中示例使用的服务地址:后台页面传入浏览器看到的地址(经反向代理时与服务自身看到的不同),否则用请求的地址 */
+function referenceBase(req: FastifyRequest<{ Querystring: { base?: string } }>): string {
+  const b = req.query.base
+  if (b && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b)) return b
+  return `${req.protocol}://${req.host}`
+}
 
 type IdParams = { Params: { id: string } }
 
@@ -65,7 +74,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     g.get('/overview', async () => ({
       providers: await providerStatuses(app),
       runs: app.runs.stats(),
-      version: '0.1.0',
+      version: APP_VERSION,
     }))
 
     g.get('/providers', async () => providerStatuses(app))
@@ -76,6 +85,27 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return provider.quota(app.settings.get())
     })
 
+    // 对外 API 协议:HTML(后台页面内嵌显示)与 PDF 下载
+    type RefQuery = { Querystring: { theme?: string; base?: string } }
+    const referenceHtml = (req: FastifyRequest<RefQuery>, theme: 'light' | 'dark') => renderApiReferenceHtml({
+      baseUrl: referenceBase(req), theme, version: `v1 · 服务 ${APP_VERSION}`, generatedAt: new Date(),
+    })
+    g.get('/api-reference/meta', async () => ({ pdfAvailable: app.apiPdf.available }))
+    g.get<RefQuery>('/api-reference.html', async (req, reply) => {
+      reply.header('Content-Type', 'text/html; charset=utf-8')
+      // 文档不需要脚本
+      reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'")
+      return referenceHtml(req, req.query.theme === 'dark' ? 'dark' : 'light')
+    })
+    g.get<RefQuery>('/api-reference.pdf', async (req, reply) => {
+      if (!app.apiPdf.available) throw new AppError(503, 'pdf_unavailable', '服务器上没有安装 Chrome / Chromium,请使用浏览器打印保存为 PDF')
+      const pdf = await app.apiPdf.render(referenceHtml(req, 'light'))
+      const name = encodeURIComponent(`AgentServer API 协议 ${new Date().toISOString().slice(0, 10)}.pdf`)
+      reply.header('Content-Type', 'application/pdf')
+      reply.header('Content-Disposition', `attachment; filename*=UTF-8''${name}`)
+      return reply.send(pdf)
+    })
+
     // 用量统计(全部来源)与最近的失败
     g.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30 }))
     g.get<{ Querystring: { limit?: string } }>('/usage/errors', async req => app.usage.recentErrors(Number(req.query.limit ?? 10) || 10))

+ 6 - 0
apps/server/src/http/routes/v1.ts

@@ -37,7 +37,13 @@ export async function providerStatuses(app: App): Promise<ProviderStatusDto[]> {
   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)

+ 70 - 0
apps/server/test/api-reference.test.ts

@@ -0,0 +1,70 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import pino from 'pino'
+import { createApp, type App } from '../src/app'
+import { API_EVENTS, API_GROUPS } from '../src/api-reference/reference'
+import { renderApiReferenceHtml } from '../src/api-reference/render'
+import { findChrome } from '../src/api-reference/pdf'
+import { buildServer } from '../src/http/server'
+import { registeredV1Routes } from '../src/http/routes/v1'
+import type { EventPayloads } from '@agent-server/shared'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+})
+afterAll(() => server.close())
+
+describe('API 协议文档', () => {
+  it('与实际注册的 /v1 路由完全一致(新增或删除接口时必须同步修改 reference.ts)', () => {
+    const key = (m: string, p: string) => `${m} ${p}`
+    const actual = new Set(registeredV1Routes.map(r => key(r.method, r.path)))
+    const documented = new Set(API_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
+    expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
+    expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
+  })
+
+  it('事件类型与统一事件模型一致', () => {
+    // EventPayloads 的键在类型层面,这里用一个完整的对象字面量确保编译期同步
+    const all: Record<keyof EventPayloads, true> = {
+      'user.message': true, 'run.queued': true, 'run.started': true, 'message.delta': true, 'message.completed': true,
+      'reasoning.delta': true, 'reasoning.completed': true, 'tool.started': true, 'tool.completed': true, 'file.changed': true,
+      'interaction.required': true, 'interaction.resolved': true, usage: true, 'run.completed': true, 'run.failed': true, 'run.interrupted': true,
+    }
+    expect(API_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
+  })
+
+  it('渲染:示例使用传入的服务地址;转义特殊字符;没有脚本', () => {
+    const html = renderApiReferenceHtml({ baseUrl: 'https://agent.example.com/', theme: 'dark', version: 'v1', generatedAt: new Date('2026-10-23T00:00:00Z') })
+    expect(html).toContain('curl -X POST https://agent.example.com/v1/sessions')
+    expect(html).not.toContain('$BASE')
+    expect(html).toContain('data-theme="dark"')
+    expect(html).toContain('&lt;凭证&gt;')
+    expect(html).not.toMatch(/<script/i)
+  })
+
+  it('后台接口:HTML 需要登录且带 CSP;base 参数只接受 http(s) 源', async () => {
+    expect((await server.inject({ method: 'GET', url: '/admin/v1/api-reference.html' })).statusCode).toBe(401)
+    const cookies = { agent_admin: app.adminAuth.login('test-password').id }
+    const r = await server.inject({ method: 'GET', url: '/admin/v1/api-reference.html?base=https://agent.zenpower.cc', cookies })
+    expect(r.headers['content-security-policy']).toContain("default-src 'none'")
+    expect(r.body).toContain('https://agent.zenpower.cc/v1/sessions')
+    const bad = await server.inject({ method: 'GET', url: `/admin/v1/api-reference.html?base=${encodeURIComponent('javascript:alert(1)')}`, cookies })
+    expect(bad.body).not.toContain('javascript:')
+    expect((await server.inject({ method: 'GET', url: '/admin/v1/api-reference/meta', cookies })).json()).toHaveProperty('pdfAvailable')
+  })
+})
+
+describe.skipIf(!findChrome())('PDF', () => {
+  it('后台下载 PDF:A4 文档、附件文件名', async () => {
+    const cookies = { agent_admin: app.adminAuth.login('test-password').id }
+    const r = await server.inject({ method: 'GET', url: '/admin/v1/api-reference.pdf', cookies })
+    expect(r.statusCode).toBe(200)
+    expect(r.headers['content-type']).toBe('application/pdf')
+    expect(r.headers['content-disposition']).toMatch(/^attachment; filename\*=UTF-8''AgentServer/)
+    expect(r.rawPayload.subarray(0, 5).toString()).toBe('%PDF-')
+    expect(r.rawPayload.length).toBeGreaterThan(20_000)
+  }, 60_000)
+})

+ 5 - 4
docs/design/04-api.md

@@ -1,5 +1,7 @@
 # 04 对外 API 与事件模型
 
+> **接口细节以后台"API 协议"页面为准**:它由 `apps/server/src/api-reference/reference.ts` 生成,测试会核对其中的接口与实际注册的 `/v1` 路由完全一致,并可下载为 PDF。本文记录设计思路与约定。
+
 ## 1. 约定
 
 - 基础路径:`/v1`(业务)、`/admin/v1`(管理,仅后台会话可访问)、`/mcp`(MCP Gateway,仅 Run Token 可访问)
@@ -35,7 +37,7 @@
 ### 交互
 | 方法 | 路径 | 说明 |
 |---|---|---|
-| GET | `/v1/interactions?status=pending` | 未决交互 |
+| GET | `/v1/interactions?sessionId=` | 某个会话未处理的交互 |
 | POST | `/v1/interactions/:id` | 回复(见 §4) |
 
 ### 文件
@@ -44,7 +46,6 @@
 | POST | `/v1/sessions/:id/files` | multipart 上传(数据文件 / 截图),返回 `fileId`;写入工作区 `uploads/` |
 | GET | `/v1/sessions/:id/files` | 列出工作区文件树(uploads / outputs) |
 | GET | `/v1/files/:fileId` | 下载;`?inline=1` 用于预览 |
-| GET | `/v1/files/:fileId/preview` | 预览渲染:图片缩略图、CSV/xlsx 前 N 行、docx/pdf 转 PDF/HTML |
 
 ### 其他
 | 方法 | 路径 | 说明 |
@@ -75,11 +76,11 @@ interface AgentEventEnvelope<T extends string, P> {
 | `message.completed` | `{ messageId, text }` | 助手一段完整文本 |
 | `reasoning.delta` | `{ blockId, text }` | 思考过程(前端默认折叠) |
 | `tool.started` | `{ toolCallId, name, title, input }` | `title` 是适配层生成的人类可读摘要,如 `Read sales.xlsx` |
-| `tool.progress` | `{ toolCallId, output }` | 命令输出增量(可选) |
 | `tool.completed` | `{ toolCallId, status: 'ok'\|'error'\|'denied', output, truncated }` | |
 | `file.changed` | `{ fileId, path, kind: 'created'\|'modified', size, mime }` | 工作区产出文件 |
 | `interaction.required` | 见 §4 | 需人工介入,Run 进入 `waiting_input` |
-| `interaction.resolved` | `{ interactionId, resolution, by }` | 已回复/超时/取消 |
+| `interaction.resolved` | `{ interactionId, resolution, answers?, reason? }` | 已回复/超时/取消 |
+| `reasoning.completed` | `{ blockId, text }` | 一段完整的思考过程 |
 | `usage` | `{ inputTokens, outputTokens, cacheReadTokens, costUsd? }` | 订阅登录下 `costUsd` 为估算值 |
 | `run.completed` | `{ durationMs }` | |
 | `run.failed` | `{ code, message }` | |

+ 1 - 0
docs/design/09-admin-ui.md

@@ -28,6 +28,7 @@
 | 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
+| 📖 API 协议 | — | 对外接口(/v1)的完整协议:由 `apps/server/src/api-reference/reference.ts` 生成(测试保证与实际路由一致),跟随亮/暗主题;下载 PDF 由服务端无头 Chrome 生成(A4,服务器没有 Chrome 时改用浏览器打印) |
 | 🔌 连接器 | (暂缓) | — |
 | 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |
 | ⚙ 设置 | 分组:路径 / 安全 / 网络 / 限制 / 引擎 | **路径**:工作区根目录、技能目录、Python 环境、matplotlibrc;**安全**:全局禁止路径(内置项锁定)、新建 Profile 的默认文件访问模式;**网络**:域名白名单、私网拦截;**限制**:并发、交互超时、上传大小、保留期;**引擎**:CLI 路径、默认模型、Codex 配置覆盖项。所有路径输入都做实时校验 |