Procházet zdrojové kódy

feat: API 协议页面增加右侧目录导航与接口调试

- 页面改为在后台按协议定义渲染(数据来自 /admin/v1/api-reference.json,与 PDF 同源),右侧目录随滚动高亮,
  当前分组展开显示接口
- 每个接口可以调试:路径参数、查询参数、JSON 请求体(预填示例)、文件上传;可选"后台管理员"或某个平台身份
  (服务端为该 Key 签发 10 分钟浏览器令牌,API Key 不离开服务端,记入审计)
- 写操作发送前确认,发送消息提示会消耗订阅额度;事件流实时显示(最多 2 分钟 / 200 条,可停止);
  下载接口给出保存链接;响应中的会话 / Run / 交互 / 文件 ID 自动预填到其他接口
- 协议参考的类型移到 shared;概述文字也纳入协议定义,页面与 PDF 共用

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang před 1 týdnem
rodič
revize
5cca09414f

+ 211 - 0
apps/admin/src/components/ApiTryPanel.vue

@@ -0,0 +1,211 @@
+<script setup lang="ts">
+// API 协议页面中单个接口的调试面板:填写参数 → 以选定身份调用 → 显示响应
+import { computed, onBeforeUnmount, reactive, ref } from 'vue'
+import type { ApiEndpoint } from '@agent-server/shared'
+
+const props = defineProps<{
+  endpoint: ApiEndpoint
+  /** 当前调试身份的 Authorization 值;null 表示用后台登录态(admin 身份) */
+  auth: () => Promise<string | null>
+  identityLabel: string
+  /** 最近响应中出现过的 ID(按前缀),用于预填路径参数 */
+  recentIds: Record<string, string>
+}>()
+const emit = defineEmits<{ ids: [ids: Record<string, string>] }>()
+
+const ep = props.endpoint
+const isStream = ep.path.endsWith('/stream')
+const isUpload = ep.method === 'POST' && ep.path.endsWith('/files')
+const isDownload = ep.method === 'GET' && ep.path === '/v1/files/:id'
+const startsRun = ep.path.endsWith('/messages')
+
+/** 路径参数:按前一段推断 ID 类型(sessions/:id → ses_) */
+const PREFIX: Record<string, { prefix: string; label: string }> = {
+  sessions: { prefix: 'ses_', label: '会话 ID' }, runs: { prefix: 'run_', label: 'Run ID' },
+  interactions: { prefix: 'itx_', label: '交互 ID' }, files: { prefix: 'file_', label: '文件 ID' },
+}
+const pathParams = [...ep.path.matchAll(/([a-z-]+)\/:(\w+)/g)].map(m => ({ name: m[2]!, ...(PREFIX[m[1]!] ?? { prefix: '', label: m[2]! }) }))
+const params = reactive<Record<string, string>>(Object.fromEntries(pathParams.map(p => [p.name, props.recentIds[p.prefix] ?? ''])))
+const query = reactive<Record<string, string>>(Object.fromEntries((ep.query ?? []).map(q => [q.name, ''])))
+const hasBody = !!(ep.body?.length || ep.sampleBody !== undefined)
+const body = ref(hasBody ? JSON.stringify(ep.sampleBody ?? {}, null, 2) : '')
+const files = ref<File[]>([])
+
+const running = ref(false)
+const result = ref<{ status: number; ok: boolean; ms: number; contentType: string; text: string; download?: { url: string; name: string; size: number } } | null>(null)
+const streamLines = ref<string[]>([])
+let abort: AbortController | null = null
+
+const url = computed(() => {
+  let p = ep.path
+  for (const pp of pathParams) p = p.replace(`:${pp.name}`, encodeURIComponent(params[pp.name]!.trim()))
+  const qs = Object.entries(query).filter(([, v]) => v.trim()).map(([k, v]) => `${k}=${encodeURIComponent(v.trim())}`).join('&')
+  return qs ? `${p}?${qs}` : p
+})
+const missing = computed(() => pathParams.some(p => !params[p.name]!.trim()) || (ep.query ?? []).some(q => q.required && !query[q.name]!.trim()))
+
+/** 从响应中收集 ID,供其他接口的路径参数预填 */
+function collectIds(v: unknown, out: Record<string, string> = {}, depth = 0): Record<string, string> {
+  if (depth > 4 || v === null || typeof v !== 'object') return out
+  for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
+    if (typeof x === 'string' && /^(id|sessionId|runId|interactionId|fileId|activeRunId)$/.test(k)) {
+      const m = /^(ses|run|itx|file)_[0-9a-z]+$/.exec(x)
+      if (m && !out[`${m[1]}_`]) out[`${m[1]}_`] = x
+    } else if (typeof x === 'object') collectIds(x, out, depth + 1)
+  }
+  return out
+}
+
+async function send() {
+  if (ep.method !== 'GET') {
+    const warn = startsRun ? '会启动 Agent 执行,并消耗引擎的订阅额度。' : ep.method === 'DELETE' ? '删除后不可恢复。' : '会创建或修改真实数据。'
+    if (!window.confirm(`以${props.identityLabel}的身份调用 ${ep.method} ${url.value}?\n${warn}`)) return
+  }
+  let payload: BodyInit | undefined
+  const headers: Record<string, string> = {}
+  if (isUpload) {
+    if (!files.value.length) return window.alert('请先选择文件')
+    const form = new FormData()
+    for (const f of files.value) form.append('file', f, f.name)
+    payload = form
+  } else if (hasBody && body.value.trim()) {
+    try {
+      payload = JSON.stringify(JSON.parse(body.value))
+    } catch {
+      return window.alert('请求体不是有效的 JSON')
+    }
+    headers['content-type'] = 'application/json'
+  }
+  running.value = true
+  result.value = null
+  streamLines.value = []
+  abort = new AbortController()
+  const t0 = performance.now()
+  try {
+    const token = await props.auth()
+    if (token) headers.authorization = `Bearer ${token}`
+    if (isStream) headers.accept = 'text/event-stream'
+    const res = await fetch(url.value, { method: ep.method, headers, body: payload, credentials: 'same-origin', signal: abort.signal })
+    const contentType = res.headers.get('content-type') ?? ''
+    if (isStream && res.ok && res.body) {
+      result.value = { status: res.status, ok: true, ms: Math.round(performance.now() - t0), contentType, text: '' }
+      await readStream(res.body)
+      return
+    }
+    if (isDownload && res.ok) {
+      const blob = await res.blob()
+      const name = decodeURIComponent(/filename\*=UTF-8''([^;]+)/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'download')
+      result.value = { status: res.status, ok: true, ms: Math.round(performance.now() - t0), contentType, text: '', download: { url: URL.createObjectURL(blob), name, size: blob.size } }
+      return
+    }
+    const text = await res.text()
+    let shown = text
+    if (contentType.includes('json') && text) {
+      try {
+        const json = JSON.parse(text)
+        shown = JSON.stringify(json, null, 2)
+        const ids = collectIds(json)
+        if (Object.keys(ids).length) emit('ids', ids)
+      } catch {
+        // 保持原文
+      }
+    }
+    result.value = { status: res.status, ok: res.ok, ms: Math.round(performance.now() - t0), contentType, text: shown.length > 200_000 ? `${shown.slice(0, 200_000)}\n…(已截断)` : shown }
+  } catch (err) {
+    if ((err as Error).name !== 'AbortError') result.value = { status: 0, ok: false, ms: Math.round(performance.now() - t0), contentType: '', text: (err as Error).message }
+  } finally {
+    running.value = false
+    abort = null
+  }
+}
+
+/** 事件流:逐条显示,最多 2 分钟或 200 条后自动停止 */
+async function readStream(stream: ReadableStream<Uint8Array>) {
+  const reader = stream.getReader()
+  const decoder = new TextDecoder()
+  const stopAt = Date.now() + 120_000
+  let buf = ''
+  while (Date.now() < stopAt && streamLines.value.length < 200) {
+    const { value, done } = await reader.read()
+    if (done) break
+    buf += decoder.decode(value, { stream: true })
+    let i: number
+    while ((i = buf.indexOf('\n\n')) >= 0) {
+      const frame = buf.slice(0, i)
+      buf = buf.slice(i + 2)
+      const data = frame.split('\n').find(l => l.startsWith('data: '))
+      if (!data) continue
+      try {
+        const e = JSON.parse(data.slice(6)) as { seq: number; type: string; payload: unknown }
+        streamLines.value.push(`#${e.seq} ${e.type}  ${JSON.stringify(e.payload).slice(0, 300)}`)
+        const ids = collectIds(e)
+        if (Object.keys(ids).length) emit('ids', ids)
+      } catch {
+        streamLines.value.push(data)
+      }
+    }
+  }
+  abort?.abort()
+}
+
+const stop = () => abort?.abort()
+onBeforeUnmount(() => {
+  stop()
+  if (result.value?.download) URL.revokeObjectURL(result.value.download.url)
+})
+</script>
+
+<template>
+  <div class="mt-3 rounded-md border border-solid border-border bg-[var(--app-input-secondary-background)] p-3 flex flex-col gap-2.5 text-[12.5px]">
+    <div class="flex items-center gap-2 flex-wrap">
+      <span class="font-600">调试</span>
+      <span class="hint">身份:{{ identityLabel }}</span>
+      <span class="flex-1" />
+      <code class="font-mono text-[12px] break-all">{{ ep.method }} {{ url }}</code>
+    </div>
+
+    <div v-if="pathParams.length || ep.query?.length" class="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 items-center">
+      <template v-for="p in pathParams" :key="`p-${p.name}`">
+        <label class="text-fg-muted" :for="`p-${ep.path}-${p.name}`">{{ p.label }}</label>
+        <input :id="`p-${ep.path}-${p.name}`" v-model="params[p.name]" class="field font-mono" :placeholder="`${p.prefix}…`">
+      </template>
+      <template v-for="q in ep.query" :key="`q-${q.name}`">
+        <label class="text-fg-muted" :for="`q-${ep.path}-${q.name}`">{{ q.name }}<span v-if="q.required" class="text-error">*</span></label>
+        <input :id="`q-${ep.path}-${q.name}`" v-model="query[q.name]" class="field font-mono" :placeholder="q.desc">
+      </template>
+    </div>
+
+    <div v-if="isUpload">
+      <input type="file" multiple aria-label="选择上传的文件" @change="files = [...(($event.target as HTMLInputElement).files ?? [])]">
+    </div>
+    <div v-else-if="hasBody">
+      <div class="label">请求体(JSON)</div>
+      <textarea v-model="body" class="field-area h-24" spellcheck="false" aria-label="请求体" />
+    </div>
+
+    <div class="flex items-center gap-2">
+      <button type="button" class="btn-primary" :disabled="running || missing" @click="send">
+        <span class="codicon" :class="running ? 'codicon-loading codicon-modifier-spin' : 'codicon-play'" />发送
+      </button>
+      <button v-if="running && isStream" type="button" class="btn-secondary" @click="stop"><span class="codicon codicon-debug-stop" />停止</button>
+      <span v-if="ep.method !== 'GET'" class="hint text-warning">{{ startsRun ? '会启动 Agent 并消耗订阅额度' : '会改动真实数据' }},发送前需要确认</span>
+      <span v-else-if="missing" class="hint">先填写参数</span>
+    </div>
+
+    <div v-if="result" class="flex flex-col gap-1">
+      <div class="flex items-center gap-2">
+        <span class="badge" :class="result.ok ? 'bg-success text-white' : 'bg-danger text-white'">{{ result.status || '网络错误' }}</span>
+        <span class="hint">{{ result.ms }} ms<template v-if="result.contentType"> · {{ result.contentType.split(';')[0] }}</template></span>
+      </div>
+      <div v-if="result.download" class="flex items-center gap-2">
+        <span>{{ result.download.name }}({{ (result.download.size / 1024).toFixed(1) }} KB)</span>
+        <a :href="result.download.url" :download="result.download.name" class="text-link">保存文件</a>
+      </div>
+      <pre v-else-if="!isStream && result.text" class="m-0 p-2 rounded-md bg-code font-mono text-[12px] max-h-80 overflow-auto whitespace-pre-wrap break-all">{{ result.text }}</pre>
+    </div>
+    <div v-if="isStream && result?.ok" class="flex flex-col gap-1">
+      <div class="hint">{{ running ? '接收中…(最多 2 分钟或 200 条)' : streamLines.length ? `已停止,共 ${streamLines.length} 条` : '已停止,没有收到事件(该会话还没有对话;保活的 ping 不显示)' }}</div>
+      <pre v-if="streamLines.length" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] max-h-80 overflow-auto whitespace-pre-wrap break-all">{{ streamLines.join('\n') }}</pre>
+    </div>
+  </div>
+</template>

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

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
@@ -30,6 +30,8 @@ export const admin = {
     logout: () => req<{ ok: true }>('POST', '/providers/codex/logout'),
   },
   providerQuota: (id: string) => req<ProviderQuotaDto>('GET', `/providers/${id}/quota`),
+  apiReference: () => req<ApiReferenceDto>('GET', '/api-reference.json'),
+  apiKeyDebugToken: (id: string) => req<BrowserTokenDto>('POST', `/api-keys/${id}/debug-token`),
   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}`),

+ 205 - 27
apps/admin/src/views/ApiDocsView.vue

@@ -1,31 +1,87 @@
 <script setup lang="ts">
-// 对外 API 协议:服务端按代码中的协议定义渲染(与实际接口一致,由测试保证),这里内嵌显示并提供 PDF 下载
-import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+// 对外 API 协议:按服务端的协议定义渲染(与实际接口一致,由测试保证),右侧目录导航,每个接口可以直接调试。
+// 下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import type { ApiEndpoint, ApiKeyDto, ApiReferenceDto } from '@agent-server/shared'
+import ApiTryPanel from '../components/ApiTryPanel.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { toast } from '../lib/ui'
 
-const frame = ref<HTMLIFrameElement>()
+const ref_ = ref<ApiReferenceDto | null>(null)
+const keys = ref<ApiKeyDto[]>([])
 const pdfAvailable = ref(false)
 const downloading = ref(false)
+const base = location.origin
+
+// ---- 渲染 ----
+const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
+/** 与服务端渲染相同的标记:`代码`、**加粗**,$BASE 替换为服务地址 */
+const marked = (s: string) => esc(s.replaceAll('$BASE', base)).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
+const errorParts = (x: string) => /^(\d{3}) ([a-z_]+)(.*)$/.exec(x)
+const anchor = (e: ApiEndpoint) => `${e.method}-${e.path}`.toLowerCase().replace(/[^a-z0-9]+/g, '-')
+const METHOD_CLASS: Record<string, string> = { GET: 'bg-[#1a7f37]', POST: 'bg-[#0969da]', PATCH: 'bg-[#9a6700]', DELETE: 'bg-[#cf222e]' }
 
-// 文档配色跟随后台当前的亮/暗主题
-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 toc = computed(() => {
+  const r = ref_.value
+  if (!r) return []
+  return [
+    { id: 'overview', title: '概述', children: [] as { id: string; title: string; method: string }[] },
+    ...r.groups.map(g => ({ id: g.id, title: g.title, children: g.endpoints.map(e => ({ id: anchor(e), title: e.summary, method: e.method })) })),
+    { id: 'event-types', title: '事件类型', children: [] },
+    { id: 'types', title: '数据结构', children: [] },
+    { id: 'errors', title: '错误码', children: [] },
+  ]
+})
+const scroller = ref<HTMLElement>()
+const active = ref('overview')
+let observer: IntersectionObserver | null = null
+function go(id: string) {
+  active.value = id
+  document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+}
+/** 滚到底部时最后几节的标题到不了视口上部,直接选中最后一节 */
+function onScroll() {
+  const el = scroller.value
+  if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 4) active.value = 'errors'
+}
+/** 当前位置:视口上部最后一个越过的标题 */
+function observeHeadings() {
+  observer?.disconnect()
+  const visible = new Map<string, number>()
+  observer = new IntersectionObserver(entries => {
+    for (const e of entries) {
+      if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top)
+      else visible.delete(e.target.id)
+    }
+    const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0]
+    if (top) active.value = top[0]
+  }, { root: scroller.value, rootMargin: '0px 0px -70% 0px' })
+  for (const el of scroller.value?.querySelectorAll('[data-toc]') ?? []) observer.observe(el)
 }
-const observer = new MutationObserver(updateTheme)
+const activeGroup = computed(() => toc.value.find(t => t.id === active.value || t.children.some(c => c.id === active.value))?.id)
 
-// 示例中的地址用浏览器看到的地址(经反向代理时与服务自身看到的不同)
-const base = location.origin
-const src = computed(() => `/admin/v1/api-reference.html?theme=${dark.value ? 'dark' : 'light'}&base=${encodeURIComponent(base)}`)
+// ---- 调试 ----
+const trying = reactive<Record<string, boolean>>({})
+/** 调试身份:'' 为后台管理员;否则为 API Key id(服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端) */
+const identity = ref('')
+const identityLabel = computed(() => (identity.value ? `平台「${keys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
+const tokens = new Map<string, { token: string; exp: number }>()
+async function auth(): Promise<string | null> {
+  const id = identity.value
+  if (!id) return null
+  const t = tokens.get(id)
+  if (t && t.exp - Date.now() > 60_000) return t.token
+  const r = await admin.apiKeyDebugToken(id)
+  tokens.set(id, { token: r.token, exp: Date.parse(r.expiresAt) })
+  return r.token
+}
+const recentIds = reactive<Record<string, string>>({})
 
 async function downloadPdf() {
   if (!pdfAvailable.value) {
-    // 服务器没有 Chrome 时改用浏览器打印(在打印对话框中选择"另存为 PDF")
-    frame.value?.contentWindow?.print()
+    window.open(htmlUrl.value, '_blank')?.addEventListener('load', e => (e.target as Window).print())
     return
   }
   downloading.value = true
@@ -45,27 +101,149 @@ async function downloadPdf() {
     downloading.value = false
   }
 }
+const htmlUrl = computed(() => `/admin/v1/api-reference.html?base=${encodeURIComponent(base)}`)
 
 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()
+  try {
+    ;[ref_.value, keys.value, pdfAvailable.value] = await Promise.all([
+      admin.apiReference(), admin.apiKeys(), admin.apiReferenceMeta().then(m => m.pdfAvailable).catch(() => false),
+    ])
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+  await nextTick()
+  observeHeadings()
 })
+onBeforeUnmount(() => 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">
+    <PageHeader title="API 协议" :subtitle="ref_ ? `${ref_.version} · 由服务代码中的协议定义生成,与实际接口保持一致` : ''">
+      <a :href="htmlUrl" 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 v-if="ref_" class="flex-1 min-h-0 flex">
+      <div ref="scroller" class="flex-1 min-w-0 overflow-y-auto" @scroll.passive="onScroll">
+        <article class="doc max-w-220 mx-auto px-8 py-6">
+          <h2 id="overview" data-toc>概述</h2>
+          <ul><li v-for="(p, i) in ref_.overview.points" :key="i" v-html="marked(p)" /></ul>
+          <h4>典型流程</h4>
+          <ol><li v-for="(p, i) in ref_.overview.flow" :key="i" v-html="marked(p)" /></ol>
+
+          <template v-for="g in ref_.groups" :key="g.id">
+            <h2 :id="g.id" data-toc>{{ g.title }}</h2>
+            <p v-if="g.desc" class="muted" v-html="marked(g.desc)" />
+            <section v-for="e in g.endpoints" :id="anchor(e)" :key="anchor(e)" class="endpoint" data-toc>
+              <h3>
+                <span class="method" :class="METHOD_CLASS[e.method]">{{ e.method }}</span>
+                <code class="path">{{ e.path }}</code>
+                <span class="muted font-500">{{ e.summary }}</span>
+                <span class="flex-1" />
+                <button type="button" class="btn-ghost !h-6" :class="{ '!bg-hover': trying[anchor(e)] }" @click="trying[anchor(e)] = !trying[anchor(e)]">
+                  <span class="codicon codicon-debug-alt" />{{ trying[anchor(e)] ? '收起调试' : '调试' }}
+                </button>
+              </h3>
+              <p v-if="e.desc" v-html="marked(e.desc)" />
+              <template v-if="e.query?.length">
+                <h4>查询参数</h4>
+                <table><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead>
+                  <tbody><tr v-for="f in e.query" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td>{{ f.required ? '是' : '' }}</td><td v-html="marked(f.desc)" /></tr></tbody></table>
+              </template>
+              <template v-if="e.body?.length">
+                <h4>请求体(JSON)</h4>
+                <table><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead>
+                  <tbody><tr v-for="f in e.body" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td>{{ f.required ? '是' : '' }}</td><td v-html="marked(f.desc)" /></tr></tbody></table>
+              </template>
+              <template v-if="e.bodyNote"><h4>请求体</h4><p v-html="marked(e.bodyNote)" /></template>
+              <h4>响应</h4>
+              <p><b>{{ e.response.status }}</b> <code class="muted">{{ e.response.type }}</code><template v-if="e.response.desc"> — <span v-html="marked(e.response.desc)" /></template></p>
+              <template v-if="e.errors?.length">
+                <h4>常见错误</h4>
+                <ul>
+                  <li v-for="x in e.errors" :key="x">
+                    <template v-if="errorParts(x)"><b>{{ errorParts(x)![1] }}</b> <code>{{ errorParts(x)![2] }}</code><span v-html="marked(errorParts(x)![3]!)" /></template>
+                    <span v-else v-html="marked(x)" />
+                  </li>
+                </ul>
+              </template>
+              <template v-if="e.example"><h4>示例</h4><pre>{{ e.example.replaceAll('$BASE', base) }}</pre></template>
+              <ApiTryPanel
+                v-if="trying[anchor(e)]" :key="`${anchor(e)}-${identity}`" :endpoint="e" :auth="auth" :identity-label="identityLabel" :recent-ids="recentIds"
+                @ids="ids => Object.assign(recentIds, ids)"
+              />
+            </section>
+          </template>
+
+          <h2 id="event-types" data-toc>事件类型</h2>
+          <p class="muted">事件信封见"数据结构 › AgentEvent"。"落库"为否的事件只在实时事件流中推送,历史事件中只保留对应的 completed 事件。</p>
+          <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead>
+            <tbody><tr v-for="ev in ref_.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
+
+          <h2 id="types" data-toc>数据结构</h2>
+          <template v-for="t in ref_.types" :key="t.name">
+            <h3><code class="path">{{ t.name }}</code></h3>
+            <p v-if="t.desc" class="muted" v-html="marked(t.desc)" />
+            <table><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead>
+              <tbody><tr v-for="f in t.fields" :key="f.name"><td><code>{{ f.name }}</code></td><td><code class="muted">{{ f.type }}</code></td><td v-html="marked(f.desc)" /></tr></tbody></table>
+          </template>
+
+          <h2 id="errors" data-toc>错误码</h2>
+          <table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
+            <tbody><tr v-for="er in ref_.errors" :key="er.code"><td>{{ er.status }}</td><td><code>{{ er.code }}</code></td><td v-html="marked(er.desc)" /></tr></tbody></table>
+        </article>
+      </div>
+
+      <!-- 右侧:调试身份 + 目录导航 -->
+      <aside class="w-64 flex-none border-l border-l-solid border-border overflow-y-auto py-4 px-3 flex flex-col gap-4 text-[12.5px]">
+        <div class="flex flex-col gap-1">
+          <label class="label m-0" for="try-identity">调试身份</label>
+          <select id="try-identity" v-model="identity" class="field">
+            <option value="">后台管理员</option>
+            <option v-for="k in keys.filter(x => x.enabled)" :key="k.id" :value="k.id">平台:{{ k.name }}</option>
+          </select>
+          <div class="hint">{{ identity ? '与该平台完全相同的权限与可见范围(使用 10 分钟的临时令牌)' : '可见全部 Profile;创建的会话会出现在 Playground 中' }}</div>
+        </div>
+        <nav class="flex flex-col" aria-label="目录">
+          <div class="section-title mb-1">目录</div>
+          <template v-for="t in toc" :key="t.id">
+            <a
+              :href="`#${t.id}`" class="block px-2 py-1 rounded-sm no-underline hover:bg-hover"
+              :class="activeGroup === t.id ? 'text-fg font-600' : 'text-fg-muted'" @click.prevent="go(t.id)"
+            >{{ t.title }}</a>
+            <template v-if="t.children.length && activeGroup === t.id">
+              <a
+                v-for="c in t.children" :key="c.id" :href="`#${c.id}`"
+                class="flex items-center gap-1.5 pl-4 pr-2 py-0.5 rounded-sm no-underline hover:bg-hover truncate"
+                :class="active === c.id ? 'text-link' : 'text-fg-muted'" @click.prevent="go(c.id)"
+              >
+                <span class="font-mono text-[10px] w-9 flex-none">{{ c.method }}</span><span class="truncate">{{ c.title }}</span>
+              </a>
+            </template>
+          </template>
+        </nav>
+      </aside>
+    </div>
   </div>
 </template>
+
+<style scoped>
+.doc { line-height: 1.65; }
+.doc :deep(h2) { font-size: 18px; margin: 32px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--app-primary-border-color); scroll-margin-top: 12px; }
+.doc h2:first-child { margin-top: 0; }
+.doc :deep(h3) { font-size: 14px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; scroll-margin-top: 12px; }
+.doc :deep(h4) { font-size: 12px; margin: 12px 0 4px; color: var(--app-secondary-foreground); }
+.doc :deep(p), .doc :deep(ul), .doc :deep(ol) { margin: 6px 0; }
+.doc :deep(code) { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 1px 5px; border-radius: 4px; }
+.doc :deep(pre) { font-family: var(--app-monospace-font-family); font-size: 12px; background: var(--app-code-background); padding: 10px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; margin: 6px 0; }
+.doc :deep(table) { border-collapse: collapse; width: 100%; margin: 6px 0; }
+.doc :deep(th), .doc :deep(td) { border: 1px solid var(--app-primary-border-color); padding: 4px 8px; text-align: left; vertical-align: top; }
+.doc :deep(th) { background: var(--app-code-background); font-weight: 600; white-space: nowrap; }
+.doc .muted { color: var(--app-secondary-foreground); }
+.doc .endpoint { border: 1px solid var(--app-primary-border-color); border-radius: 8px; padding: 12px 14px; margin: 12px 0; scroll-margin-top: 12px; }
+.doc .method { font: 700 11px/1 sans-serif; color: #fff; padding: 4px 7px; border-radius: 4px; }
+.doc .path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
+</style>

+ 28 - 41
apps/server/src/api-reference/reference.ts

@@ -1,48 +1,30 @@
 // 对外 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
-}
+import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
 
-export interface EndpointGroup {
-  id: string
-  title: string
-  desc?: string
-  endpoints: Endpoint[]
-}
-
-export interface TypeDef {
-  name: string
-  desc?: string
-  fields: Field[]
-}
+export type { ApiEndpoint as Endpoint, ApiField as Field } from '@agent-server/shared'
 
-export interface EventDef {
-  type: string
-  payload: string
-  desc: string
-  persisted: boolean
+export const API_OVERVIEW: ApiOverview = {
+  points: [
+    '**基础地址**:`$BASE`,所有接口位于 `/v1` 下;请求和响应为 JSON(UTF-8),时间为 ISO 8601。',
+    '**鉴权**:`Authorization: Bearer <凭证>`。**API Key**(`ask_…`)每个业务平台一个,在后台签发,只在平台后端使用;**浏览器令牌**(`bt_…`)由平台后端调用 `POST /v1/browser-tokens` 换取,交给浏览器使用,默认 1 小时有效。',
+    '**可见范围**:每个 API Key 只能使用后台为它勾选的 Profile,只能看到自己创建的会话。',
+    '**ID 前缀**:会话 `ses_`、Run `run_`、交互 `itx_`、文件 `file_`、Profile `prf_`。',
+    '**错误格式**:`{ "error": { "code": "session_busy", "message": "…", "details": {} } }`,错误码见最后一节。',
+    '**跨域**:浏览器直接调用时,需在后台"设置 › 安全 › CORS 来源"中加入平台地址;请求不携带 Cookie。',
+    '**SDK**:TypeScript 客户端与 Vue 对话组件,安装地址见 `$BASE/sdk/index.json`,用法见接入指南(docs/integration.md)。',
+  ],
+  flow: [
+    '平台后端换取浏览器令牌,前端 `POST /v1/sessions` 创建会话;',
+    '前端订阅 `GET /v1/sessions/:id/stream`;需要附件时先 `POST /v1/sessions/:id/files` 上传;',
+    '`POST /v1/sessions/:id/messages` 发送消息,事件流中依次出现文本、工具调用、产出文件;',
+    '出现 `interaction.required` 时由用户决定,调用 `POST /v1/interactions/:id` 回复;',
+    '收到 `run.completed` 后,用 `GET /v1/files/:id` 下载产出文件。',
+  ],
 }
 
-export const API_GROUPS: EndpointGroup[] = [
+export const API_GROUPS: ApiEndpointGroup[] = [
   {
     id: 'auth',
     title: '令牌',
@@ -53,6 +35,7 @@ export const API_GROUPS: EndpointGroup[] = [
         desc: '只能使用 API Key 调用(浏览器令牌不能再签发令牌)。令牌的权限与签发它的 API Key 相同;停用或删除 API Key 后,它签发的全部令牌立即失效。',
         body: [{ name: 'ttlSeconds', type: 'number', desc: '有效期(秒),60 ~ 43200,默认 3600' }],
         response: { status: 201, type: 'BrowserToken' },
+        sampleBody: { ttlSeconds: 600 },
         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" \\
@@ -92,6 +75,7 @@ export const API_GROUPS: EndpointGroup[] = [
           { name: 'systemPromptAppend', type: 'string', desc: '追加在 Profile 系统提示词之后的会话级提示(≤ 20000 字)' },
         ],
         response: { status: 201, type: 'Session' },
+        sampleBody: { title: 'API 调试' },
         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":"三季度销售分析"}'`,
@@ -122,6 +106,7 @@ export const API_GROUPS: EndpointGroup[] = [
           { name: 'profileId', type: 'string', desc: '更换 Profile(只能在还没有对话时)' },
         ],
         response: { status: 200, type: 'Session' },
+        sampleBody: { title: 'API 调试(已改名)' },
         errors: ['409 session_busy:运行中不能修改模型、提示词或 Profile', '400 bad_request:已经开始对话后更换 Profile'],
       },
       {
@@ -144,6 +129,7 @@ export const API_GROUPS: EndpointGroup[] = [
           { name: 'attachments', type: 'string[]', desc: '附件的 fileId(先通过上传接口上传);图片会直接交给模型识别' },
         ],
         response: { status: 202, type: 'Run', desc: 'Run 已排队,之后的进展通过事件流获得' },
+        sampleBody: { text: '你好,用一句话介绍你自己。' },
         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" \\
@@ -200,6 +186,7 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
         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 }' },
+        sampleBody: { decision: 'allow_once' },
         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"}'`,
@@ -246,7 +233,7 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
   },
 ]
 
-export const API_TYPES: TypeDef[] = [
+export const API_TYPES: ApiTypeDef[] = [
   {
     name: 'BrowserToken', fields: [
       { name: 'token', type: 'string', desc: '以 bt_ 开头,放在 Authorization: Bearer 中使用' },
@@ -356,7 +343,7 @@ export const API_TYPES: TypeDef[] = [
   },
 ]
 
-export const API_EVENTS: EventDef[] = [
+export const API_EVENTS: ApiEventDef[] = [
   { 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 },
@@ -375,7 +362,7 @@ export const API_EVENTS: EventDef[] = [
   { type: 'run.interrupted', payload: '{ reason: "user" | "restart" | "timeout" }', desc: 'Run 被中断', persisted: true },
 ]
 
-export const API_ERRORS: { status: number; code: string; desc: string }[] = [
+export const API_ERRORS: ApiErrorDef[] = [
   { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
   { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },
   { status: 401, code: 'invalid_api_key', desc: 'API Key 无效或已停用' },

+ 5 - 22
apps/server/src/api-reference/render.ts

@@ -1,5 +1,5 @@
 // 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):后台页面内嵌显示,也用于生成 PDF
-import { API_ERRORS, API_EVENTS, API_GROUPS, API_TYPES, type Endpoint, type Field } from './reference'
+import { API_ERRORS, API_EVENTS, API_GROUPS, API_OVERVIEW, API_TYPES, type Endpoint, type Field } from './reference'
 
 export interface RenderOptions {
   /** 文档中示例使用的服务地址 */
@@ -12,6 +12,8 @@ export interface RenderOptions {
 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>')
+/** 概述条目:`代码`、**加粗**,$BASE 替换为服务地址 */
+const marked = (s: string, base: string) => inline(s.replaceAll('$BASE', base)).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
 /** "409 session_busy:说明" → 状态码加粗、错误码为代码样式 */
 const errorItem = (x: string) => {
   const m = /^(\d{3}) ([a-z_]+)(.*)$/.exec(x)
@@ -106,28 +108,9 @@ code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
 <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>
+<ul>${API_OVERVIEW.points.map(x => `<li>${marked(x, base)}</li>`).join('')}</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>
+<ol>${API_OVERVIEW.flow.map(x => `<li>${marked(x, base)}</li>`).join('')}</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>` : ''}

+ 13 - 0
apps/server/src/http/routes/admin.ts

@@ -11,6 +11,7 @@ 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 { API_ERRORS, API_EVENTS, API_GROUPS, API_OVERVIEW, API_TYPES } from '../../api-reference/reference'
 import { renderApiReferenceHtml } from '../../api-reference/render'
 import { APP_VERSION } from '../../config'
 
@@ -91,6 +92,10 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       baseUrl: referenceBase(req), theme, version: `v1 · 服务 ${APP_VERSION}`, generatedAt: new Date(),
     })
     g.get('/api-reference/meta', async () => ({ pdfAvailable: app.apiPdf.available }))
+    // 后台页面用同一份协议定义自行渲染(可以在每个接口旁调试);HTML 版本用于 PDF 与新窗口打开
+    g.get('/api-reference.json', async () => ({
+      version: `v1 · 服务 ${APP_VERSION}`, overview: API_OVERVIEW, groups: API_GROUPS, types: API_TYPES, events: API_EVENTS, errors: API_ERRORS,
+    }))
     g.get<RefQuery>('/api-reference.html', async (req, reply) => {
       reply.header('Content-Type', 'text/html; charset=utf-8')
       // 文档不需要脚本
@@ -291,6 +296,14 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 API Key 可用的 Profile:${req.params.id}`, detail: body })
       return { ok: true }
     })
+    // API 协议页面调试:以某个平台的身份调用 /v1,签发 10 分钟的浏览器令牌(API Key 本身不离开服务端)
+    g.post<IdParams>('/api-keys/:id/debug-token', async req => {
+      const key = app.apiKeys.list().find(k => k.id === req.params.id)
+      if (!key) throw notFound('API Key')
+      if (!key.enabled) throw new AppError(400, 'bad_request', `API Key「${key.name}」已停用`)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试接口` })
+      return app.browserTokens.issue(key.id, 600)
+    })
     g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
       app.apiKeys.delete(req.params.id)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 API Key:${req.params.id}` })

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

@@ -55,6 +55,23 @@ describe('API 协议文档', () => {
     expect(bad.body).not.toContain('javascript:')
     expect((await server.inject({ method: 'GET', url: '/admin/v1/api-reference/meta', cookies })).json()).toHaveProperty('pdfAvailable')
   })
+
+  it('调试:协议 JSON 含概述与示例请求体;以平台身份调试时签发 10 分钟令牌,停用的 Key 不能调试', async () => {
+    const cookies = { agent_admin: app.adminAuth.login('test-password').id }
+    const ref = (await server.inject({ method: 'GET', url: '/admin/v1/api-reference.json', cookies })).json()
+    expect(ref.overview.points.length).toBeGreaterThan(3)
+    expect(ref.groups.flatMap((g: { endpoints: { path: string; sampleBody?: unknown }[] }) => g.endpoints).find((e: { path: string }) => e.path === '/v1/sessions/:id/messages').sampleBody).toHaveProperty('text')
+
+    const builtin = app.profiles.getBuiltin().id
+    const k = app.apiKeys.create('调试', { allowedProfileIds: [builtin], defaultProfileId: builtin })
+    expect((await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token` })).statusCode).toBe(401)
+    const r = await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token`, cookies })
+    const { token, expiresAt } = r.json()
+    expect(Date.parse(expiresAt) - Date.now()).toBeLessThanOrEqual(600_000)
+    expect(app.browserTokens.verify(token)).toBe(k.apiKey.id)
+    app.apiKeys.setEnabled(k.apiKey.id, false)
+    expect((await server.inject({ method: 'POST', url: `/admin/v1/api-keys/${k.apiKey.id}/debug-token`, cookies })).statusCode).toBe(400)
+  })
 })
 
 describe.skipIf(!findChrome())('PDF', () => {

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

@@ -28,7 +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 时改用浏览器打印) |
+| 📖 API 协议 | 右侧:调试身份 + 目录(随滚动高亮) | 对外接口(/v1)的完整协议:由 `apps/server/src/api-reference/reference.ts` 生成(测试保证与实际路由一致),跟随亮/暗主题。每个接口可以**调试**:填写参数与请求体后以"后台管理员"或某个平台的身份调用(平台身份由服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端,记入审计),写操作发送前确认,发送消息会提示消耗订阅额度;事件流实时显示(最多 2 分钟 / 200 条,可停止);响应中的 ID 自动预填到其他接口的路径参数。下载 PDF 由服务端无头 Chrome 生成(A4,服务器没有 Chrome 时改用浏览器打印) |
 | 🔌 连接器 | (暂缓) | — |
 | 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |
 | ⚙ 设置 | 分组:路径 / 安全 / 网络 / 限制 / 引擎 | **路径**:工作区根目录、技能目录、Python 环境、matplotlibrc;**安全**:全局禁止路径(内置项锁定)、新建 Profile 的默认文件访问模式;**网络**:域名白名单、私网拦截;**限制**:并发、交互超时、上传大小、保留期;**引擎**:CLI 路径、默认模型、Codex 配置覆盖项。所有路径输入都做实时校验 |

+ 66 - 0
packages/shared/src/api.ts

@@ -412,3 +412,69 @@ export interface BrowserTokenDto {
   token: string
   expiresAt: string
 }
+
+// ---- 对外 API 协议参考(后台"API 协议"页面,数据见 apps/server/src/api-reference/reference.ts)----
+
+export interface ApiField {
+  name: string
+  type: string
+  required?: boolean
+  desc: string
+}
+
+export interface ApiEndpoint {
+  method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
+  path: string
+  summary: string
+  desc?: string
+  query?: ApiField[]
+  body?: ApiField[]
+  /** 请求体不是 JSON 时的说明(如 multipart) */
+  bodyNote?: string
+  response: { status: number; type: string; desc?: string }
+  errors?: string[]
+  example?: string
+  /** 后台"调试"时预填的请求体 */
+  sampleBody?: unknown
+}
+
+export interface ApiEndpointGroup {
+  id: string
+  title: string
+  desc?: string
+  endpoints: ApiEndpoint[]
+}
+
+export interface ApiTypeDef {
+  name: string
+  desc?: string
+  fields: ApiField[]
+}
+
+export interface ApiEventDef {
+  type: string
+  payload: string
+  desc: string
+  persisted: boolean
+}
+
+export interface ApiErrorDef {
+  status: number
+  code: string
+  desc: string
+}
+
+/** 概述中的条目:`代码`、**加粗** 两种标记;$BASE 替换为服务地址 */
+export interface ApiOverview {
+  points: string[]
+  flow: string[]
+}
+
+export interface ApiReferenceDto {
+  version: string
+  overview: ApiOverview
+  groups: ApiEndpointGroup[]
+  types: ApiTypeDef[]
+  events: ApiEventDef[]
+  errors: ApiErrorDef[]
+}