render.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. // 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):新窗口打开与生成 PDF。两套协议(Agent API、Bot API)各为一部分
  2. import type { ApiSpec } from '@agent-server/shared'
  3. import { API_SPECS, type Endpoint, type Field } from './reference'
  4. export interface RenderOptions {
  5. /** 文档中示例使用的服务地址 */
  6. baseUrl: string
  7. theme: 'light' | 'dark'
  8. version: string
  9. generatedAt: Date
  10. }
  11. const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
  12. /** 行内代码:`xxx` → <code> */
  13. const inline = (s: string) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>')
  14. /** 概述条目:`代码`、**加粗**,$BASE 替换为服务地址 */
  15. const marked = (s: string, base: string) => inline(s.replaceAll('$BASE', base)).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
  16. /** "409 session_busy:说明" → 状态码加粗、错误码为代码样式 */
  17. const errorItem = (x: string) => {
  18. const m = /^(\d{3}) ([a-z_]+)(.*)$/.exec(x)
  19. return m ? `<li><b>${m[1]}</b> <code>${m[2]}</code>${inline(m[3]!)}</li>` : `<li>${inline(x)}</li>`
  20. }
  21. const anchor = (e: Endpoint) => `${e.method}-${e.path}`.toLowerCase().replace(/[^a-z0-9]+/g, '-')
  22. const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-')
  23. function fieldTable(fields: Field[], withRequired = true) {
  24. return `<table class="fields"><thead><tr><th>字段</th><th>类型</th>${withRequired ? '<th>必填</th>' : ''}<th>说明</th></tr></thead><tbody>${
  25. 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('')
  26. }</tbody></table>`
  27. }
  28. function endpoint(e: Endpoint, base: string) {
  29. return `<section class="endpoint" id="${anchor(e)}">
  30. <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>
  31. ${e.desc ? `<p>${inline(e.desc)}</p>` : ''}
  32. ${e.query?.length ? `<h4>查询参数</h4>${fieldTable(e.query)}` : ''}
  33. ${e.body?.length ? `<h4>请求体(JSON)</h4>${fieldTable(e.body)}` : ''}
  34. ${e.bodyNote ? `<h4>请求体</h4><p>${inline(e.bodyNote)}</p>` : ''}
  35. <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>
  36. ${e.errors?.length ? `<h4>常见错误</h4><ul>${e.errors.map(errorItem).join('')}</ul>` : ''}
  37. ${e.example ? `<h4>示例</h4><pre>${esc(e.example.replaceAll('$BASE', base))}</pre>` : ''}
  38. </section>`
  39. }
  40. /** 一部分的章节:概述、各接口分组、事件类型、数据结构、错误码(编号为"部分.节") */
  41. function sections(spec: ApiSpec) {
  42. return [
  43. ['overview', '概述'],
  44. ...spec.groups.map(g => [g.id, g.title]),
  45. ...(spec.events.length ? [['event-types', '事件类型']] : []),
  46. ['types', '数据结构'],
  47. ['errors', '错误码'],
  48. ].map(([id, title], k) => ({ id: `${spec.id}-${id}`, title: title!, no: k + 1 }))
  49. }
  50. function specHtml(spec: ApiSpec, part: number, base: string) {
  51. const sec = sections(spec)
  52. const no = (id: string) => `${part}.${sec.find(x => x.id === `${spec.id}-${id}`)!.no}`
  53. return `<section class="part" id="${spec.id}">
  54. <h2 class="part-title">第 ${part} 部分 · ${esc(spec.title)}</h2>
  55. <p class="group-desc">${inline(spec.summary)}</p>
  56. <h2 id="${spec.id}-overview">${no('overview')} 概述</h2>
  57. <ul>${spec.overview.points.map(x => `<li>${marked(x, base)}</li>`).join('')}</ul>
  58. <h4>典型流程</h4>
  59. <ol>${spec.overview.flow.map(x => `<li>${marked(x, base)}</li>`).join('')}</ol>
  60. ${spec.groups.map(g => `<h2 id="${spec.id}-${g.id}">${no(g.id)} ${esc(g.title)}</h2>
  61. ${g.desc ? `<p class="group-desc">${inline(g.desc)}</p>` : ''}
  62. ${g.endpoints.map(e => endpoint(e, base)).join('\n')}`).join('\n')}
  63. ${spec.events.length ? `<h2 id="${spec.id}-event-types">${no('event-types')} 事件类型</h2>
  64. <p>事件信封见"数据结构 › ${esc(spec.eventEnvelope)}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
  65. <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
  66. ${spec.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')}
  67. </tbody></table>
  68. ` : ''}
  69. <h2 id="${spec.id}-types">${no('types')} 数据结构</h2>
  70. ${spec.types.map(t => `<h3 id="${spec.id}-type-${slug(t.name)}"><code class="path">${esc(t.name)}</code></h3>
  71. ${t.desc ? `<p class="group-desc">${inline(t.desc)}</p>` : ''}${fieldTable(t.fields, false)}`).join('\n')}
  72. <h2 id="${spec.id}-errors">${no('errors')} 错误码</h2>
  73. <table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead><tbody>
  74. ${spec.errors.map(e => `<tr><td>${e.status}</td><td><code>${esc(e.code)}</code></td><td>${inline(e.desc)}</td></tr>`).join('\n')}
  75. </tbody></table>
  76. </section>`
  77. }
  78. export function renderApiReferenceHtml(o: RenderOptions): string {
  79. const base = o.baseUrl.replace(/\/+$/, '')
  80. const date = o.generatedAt.toISOString().slice(0, 10)
  81. return `<!doctype html>
  82. <html lang="zh-CN" data-theme="${o.theme}">
  83. <head>
  84. <meta charset="utf-8">
  85. <meta name="viewport" content="width=device-width, initial-scale=1">
  86. <title>AgentServer API 协议 ${esc(o.version)}</title>
  87. <style>
  88. :root { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --code-bg:#f6f8fa; --link:#0969da;
  89. --get:#1a7f37; --post:#0969da; --patch:#9a6700; --delete:#cf222e; }
  90. [data-theme="dark"] { --bg:#1f1f1f; --fg:#cccccc; --muted:#9d9d9d; --border:#3c3c3c; --code-bg:#2b2b2b; --link:#4daafc;
  91. --get:#3fb950; --post:#58a6ff; --patch:#d29922; --delete:#f85149; }
  92. * { box-sizing: border-box; }
  93. html, body { margin: 0; background: var(--bg); color: var(--fg); }
  94. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
  95. font-size: 13.5px; line-height: 1.65; padding: 32px 40px 64px; max-width: 980px; margin: 0 auto; }
  96. h1 { font-size: 26px; margin: 0 0 4px; }
  97. h2 { font-size: 19px; margin: 36px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
  98. h3 { font-size: 15px; margin: 0 0 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  99. h4 { font-size: 12.5px; margin: 14px 0 6px; color: var(--muted); }
  100. p, ul { margin: 6px 0; }
  101. a { color: var(--link); text-decoration: none; }
  102. code, pre { font-family: "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace; font-size: 12px; }
  103. code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
  104. code.type { color: var(--muted); }
  105. 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; }
  106. .meta { color: var(--muted); margin-bottom: 20px; }
  107. .toc { columns: 2; padding-left: 18px; margin: 8px 0 0; }
  108. table { border-collapse: collapse; width: 100%; margin: 6px 0; }
  109. th, td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; vertical-align: top; }
  110. th { background: var(--code-bg); font-weight: 600; white-space: nowrap; }
  111. td code { white-space: nowrap; }
  112. td code.type { white-space: normal; }
  113. .endpoint { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin: 14px 0; }
  114. .method { font: 700 11.5px/1 sans-serif; color: #fff; padding: 4px 7px; border-radius: 4px; }
  115. .method.get { background: var(--get); } .method.post { background: var(--post); } .method.patch { background: var(--patch); } .method.delete { background: var(--delete); }
  116. code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
  117. .summary { color: var(--muted); font-weight: 500; }
  118. .status { font-weight: 700; }
  119. .group-desc { color: var(--muted); }
  120. .no { color: var(--muted); }
  121. .part-title { font-size: 22px; border-bottom: 2px solid var(--fg); margin-top: 48px; }
  122. .part:first-of-type .part-title { margin-top: 24px; }
  123. @page { size: A4; margin: 14mm 12mm; }
  124. @media print {
  125. :root, [data-theme="dark"] { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --code-bg:#f6f8fa; --link:#0969da;
  126. --get:#1a7f37; --post:#0969da; --patch:#9a6700; --delete:#cf222e; }
  127. body { padding: 0; max-width: none; font-size: 11.5px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  128. .endpoint, table, pre { break-inside: avoid; }
  129. h2 { break-after: avoid; }
  130. .part + .part { break-before: page; }
  131. }
  132. </style>
  133. </head>
  134. <body>
  135. <h1>AgentServer 对外 API 协议</h1>
  136. <div class="meta">版本 ${esc(o.version)} · 服务地址 <code>${esc(base)}</code> · 生成于 ${date}</div>
  137. <h2>目录</h2>
  138. ${API_SPECS.map((spec, p) => `<h4><a href="#${spec.id}">第 ${p + 1} 部分 · ${esc(spec.title)}</a></h4>
  139. <ol class="toc">${sections(spec).map(x => `<li><a href="#${x.id}">${esc(x.title)}</a></li>`).join('')}</ol>`).join('\n')}
  140. ${API_SPECS.map((spec, p) => specHtml(spec, p + 1, base)).join('\n')}
  141. </body>
  142. </html>`
  143. }