| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- // 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):新窗口打开与生成 PDF。两套协议(Agent API、Bot API)各为一部分
- import type { ApiSpec } from '@agent-server/shared'
- import { API_SPECS, 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)
- 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, '-')
- const slug = (s: string) => s.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>`
- }
- /** 一部分的章节:概述、各接口分组、事件类型、数据结构、错误码(编号为"部分.节") */
- function sections(spec: ApiSpec) {
- return [
- ['overview', '概述'],
- ...spec.groups.map(g => [g.id, g.title]),
- ...(spec.events.length ? [['event-types', '事件类型']] : []),
- ['types', '数据结构'],
- ['errors', '错误码'],
- ].map(([id, title], k) => ({ id: `${spec.id}-${id}`, title: title!, no: k + 1 }))
- }
- function specHtml(spec: ApiSpec, part: number, base: string) {
- const sec = sections(spec)
- const no = (id: string) => `${part}.${sec.find(x => x.id === `${spec.id}-${id}`)!.no}`
- return `<section class="part" id="${spec.id}">
- <h2 class="part-title">第 ${part} 部分 · ${esc(spec.title)}</h2>
- <p class="group-desc">${inline(spec.summary)}</p>
- <h2 id="${spec.id}-overview">${no('overview')} 概述</h2>
- <ul>${spec.overview.points.map(x => `<li>${marked(x, base)}</li>`).join('')}</ul>
- <h4>典型流程</h4>
- <ol>${spec.overview.flow.map(x => `<li>${marked(x, base)}</li>`).join('')}</ol>
- ${spec.groups.map(g => `<h2 id="${spec.id}-${g.id}">${no(g.id)} ${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')}
- ${spec.events.length ? `<h2 id="${spec.id}-event-types">${no('event-types')} 事件类型</h2>
- <p>事件信封见"数据结构 › ${esc(spec.eventEnvelope)}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
- <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
- ${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')}
- </tbody></table>
- ` : ''}
- <h2 id="${spec.id}-types">${no('types')} 数据结构</h2>
- ${spec.types.map(t => `<h3 id="${spec.id}-type-${slug(t.name)}"><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="${spec.id}-errors">${no('errors')} 错误码</h2>
- <table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead><tbody>
- ${spec.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>
- </section>`
- }
- export function renderApiReferenceHtml(o: RenderOptions): string {
- const base = o.baseUrl.replace(/\/+$/, '')
- const date = o.generatedAt.toISOString().slice(0, 10)
- 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); }
- .part-title { font-size: 22px; border-bottom: 2px solid var(--fg); margin-top: 48px; }
- .part:first-of-type .part-title { margin-top: 24px; }
- @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; }
- .part + .part { break-before: page; }
- }
- </style>
- </head>
- <body>
- <h1>AgentServer 对外 API 协议</h1>
- <div class="meta">版本 ${esc(o.version)} · 服务地址 <code>${esc(base)}</code> · 生成于 ${date}</div>
- <h2>目录</h2>
- ${API_SPECS.map((spec, p) => `<h4><a href="#${spec.id}">第 ${p + 1} 部分 · ${esc(spec.title)}</a></h4>
- <ol class="toc">${sections(spec).map(x => `<li><a href="#${x.id}">${esc(x.title)}</a></li>`).join('')}</ol>`).join('\n')}
- ${API_SPECS.map((spec, p) => specHtml(spec, p + 1, base)).join('\n')}
- </body>
- </html>`
- }
|