feat(web): 分类展示公开 API 文档

将通用开放接口与兼容接口分层展示,补充完整接口目录、分类路由、搜索能力和接入约定。\n\n验证:pnpm nx run web:test;pnpm nx run web:build
This commit is contained in:
2026-07-22 17:12:48 +08:00
parent fbe8d1d3ec
commit 3de26d5157
7 changed files with 685 additions and 38 deletions
+273 -34
View File
@@ -6,14 +6,24 @@ import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
import {
publicApiCatalogGroups,
publicApiEndpointCount,
type PublicApiFamily,
type PublicApiMethod,
type PublicApiSurface,
} from './api-docs-catalog';
interface ApiDocItem {
catalogFamily?: PublicApiFamily;
catalogSurface?: PublicApiSurface;
group: string;
key: ApiDocSection;
kind?: TaskKind;
lead: string;
method?: 'GET' | 'POST';
method?: PublicApiMethod;
path?: string;
surface: PublicApiSurface;
title: string;
}
@@ -35,17 +45,23 @@ interface ApiGuideItem {
}
export const apiDocs: ApiDocItem[] = [
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'openApiOverview', surface: 'open', catalogSurface: 'open', group: '接口总览', title: '通用开放接口', lead: '统一的 Gateway 接口面向新业务接入,覆盖账号、模型、生成、异步任务、真人资产和安全集成;生产 Base URL 固定为 https://ai.51easyai.com/api/v1。' },
{ key: 'chat', surface: 'open', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', surface: 'open', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', surface: 'open', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', surface: 'open', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', surface: 'open', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', surface: 'open', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', surface: 'open', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', surface: 'open', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', surface: 'open', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', surface: 'open', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', surface: 'open', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'compatApiOverview', surface: 'compatibility', catalogSurface: 'compatibility', group: '接口总览', title: '兼容接口', lead: '为已有厂商 SDK 和存量业务保留请求、响应及任务协议;调用方只需替换 Base URL 和 Gateway API Key。' },
{ key: 'compatGemini', surface: 'compatibility', catalogFamily: 'gemini', group: '协议分类', title: 'Gemini 兼容接口', lead: '保留 generateContent 和 Gemini Files 请求结构,统一通过 /api/v1 前缀接入。' },
{ key: 'compatKling', surface: 'compatibility', catalogFamily: 'kling', group: '协议分类', title: '可灵兼容接口', lead: '覆盖中国区可灵官方 V1 Omni、网关 V1 和 API 2.0;上游 AK/SK 只保存在网关服务端。' },
{ key: 'compatVolces', surface: 'compatibility', catalogFamily: 'volces', group: '协议分类', title: '火山兼容接口', lead: '保留火山内容生成任务的创建、列表、查询和取消协议。' },
{ key: 'compatServerMain', surface: 'compatibility', catalogFamily: 'serverMain', group: '协议分类', title: 'server-main 兼容接口', lead: '保留 server-main 的视频提交与结果轮询响应结构。' },
];
const guideItems: ApiGuideItem[] = [
@@ -98,9 +114,14 @@ export function ApiDocsPage(props: {
const currentApiDoc = apiDocs.find((item) => item.key === props.activeDocSection) ?? (activeGuide ? undefined : apiDocs[0]);
const current = activeGuide ?? currentApiDoc ?? apiDocs[0];
const [opsSkillMetadata, setOpsSkillMetadata] = useState<GatewaySkillBundleMetadata>(defaultOpsSkillMetadata);
const [docsSearch, setDocsSearch] = useState('');
const normalizedDocsSearch = docsSearch.trim().toLowerCase();
const visibleGuideItems = guideItems.filter((item) => guideMatchesSearch(item, normalizedDocsSearch));
const visibleApiDocs = apiDocs.filter((item) => apiDocMatchesSearch(item, normalizedDocsSearch));
const isFileDoc = currentApiDoc?.key === 'files';
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
const isCatalogDoc = Boolean(currentApiDoc?.catalogSurface || currentApiDoc?.catalogFamily);
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
@@ -218,28 +239,46 @@ export function ApiDocsPage(props: {
</div>
<label className="docsSearch">
<Search size={15} />
<input placeholder="搜索" />
<input
aria-label="搜索 API 文档"
placeholder="搜索名称或路径"
value={docsSearch}
onChange={(event) => setDocsSearch(event.target.value)}
/>
</label>
<DocsGroup
title="指南"
items={guideItems.map((item) => ({
active: item.key === props.activeDocSection,
title: item.title,
onClick: () => handleGuideClick(item),
}))}
/>
{groupDocs(apiDocs).map((group) => (
{visibleGuideItems.length > 0 && (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
title="指南"
items={visibleGuideItems.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => handleDocClick(item),
onClick: () => handleGuideClick(item),
}))}
/>
)}
{groupDocsBySurface(visibleApiDocs).map((surface) => (
<section className="docsSurface" data-surface={surface.key} key={surface.key}>
<div className="docsSurfaceHeader">
<strong>{apiSurfaceTitle(surface.key)}</strong>
<span>{publicApiEndpointCount(surface.key)} </span>
</div>
{groupDocs(surface.items).map((group) => (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => handleDocClick(item),
}))}
/>
))}
</section>
))}
{normalizedDocsSearch && visibleGuideItems.length === 0 && visibleApiDocs.length === 0 && (
<div className="docsSearchEmpty"></div>
)}
</aside>
<main className="docsArticle">
@@ -253,7 +292,13 @@ export function ApiDocsPage(props: {
)}
<p className="docsLead">{current.lead}</p>
{isAsyncModeDoc ? (
{isCatalogDoc && currentApiDoc ? (
<ApiCatalogDetails
family={currentApiDoc.catalogFamily}
search={normalizedDocsSearch}
surface={currentApiDoc.catalogSurface ?? currentApiDoc.surface}
/>
) : isAsyncModeDoc ? (
<DocDetails section="asyncMode" />
) : currentApiDoc ? (
<>
@@ -406,11 +451,15 @@ export function ApiDocsPage(props: {
)}
</form> : (
<section className="docsGuideAside">
<header><strong>{isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<header><strong>{isCatalogDoc ? '接口分类' : isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
<div>
<span></span>
<strong>{current.title}</strong>
<p>{isAsyncModeDoc ? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。' : '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
<p>{isCatalogDoc
? '所有新接入路径统一以 /api/v1 开头。选择兼容协议时只替换 Base URL 和 Gateway API Key。'
: isAsyncModeDoc
? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。'
: '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
</div>
</section>
)}
@@ -430,6 +479,151 @@ export function ApiDocsPage(props: {
);
}
function ApiCatalogDetails(props: { family?: PublicApiFamily; search: string; surface: PublicApiSurface }) {
const catalogGroups = publicApiCatalogGroups.filter((group) => (
group.surface === props.surface && (!props.family || group.family === props.family)
));
const visibleGroups = catalogGroups
.map((group) => {
const groupMatches = searchText([group.title, group.description, group.family], props.search);
const endpoints = groupMatches
? group.endpoints
: group.endpoints.filter((endpoint) => searchText([endpoint.method, endpoint.path, endpoint.description], props.search));
return { ...group, endpoints };
})
.filter((group) => group.endpoints.length > 0);
const endpointCount = catalogGroups.reduce((total, group) => total + group.endpoints.length, 0);
const visibleEndpointCount = visibleGroups.reduce((total, group) => total + group.endpoints.length, 0);
const isCompatibility = props.surface === 'compatibility';
const baseUrl = props.family === 'kling'
? 'https://ai.51easyai.com/api/v1/kling'
: 'https://ai.51easyai.com/api/v1';
return (
<>
<section className="docsCatalogSummary" aria-label="API 接入摘要">
<article>
<span>{props.family === 'kling' ? '可灵客户端 Base URL' : '生产 Base URL'}</span>
<code>{baseUrl}</code>
</article>
<article>
<span></span>
<code>Authorization: Bearer {'<Gateway API Key>'}</code>
</article>
<article>
<span></span>
<strong>{endpointCount}</strong>
</article>
</section>
<section className="docsCatalogNotice" data-surface={props.surface}>
<strong>{isCompatibility ? '存量协议接入规则' : '新业务接入规则'}</strong>
<p>{isCompatibility
? '保留对应厂商或存量系统的请求字段、响应包络和任务语义。调用方替换 Base URL 与 Gateway API Key 即可,上游凭据不会下发。'
: '优先使用通用开放接口。所有路径统一位于 /api/v1;模型能力由模型目录与服务端配置决定,不要求额外传入 modelType。'}</p>
</section>
{props.surface === 'open' && <OpenApiUsageNotes />}
{isCompatibility && <CompatibilityUsageNotes family={props.family} />}
{props.search && (
<p className="docsCatalogSearchResult"> {visibleEndpointCount} / {endpointCount} </p>
)}
{visibleGroups.map((group) => (
<section className="paramCard docsCatalogGroup" key={group.family}>
<header>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<Badge variant="outline">{group.endpoints.length} </Badge>
</header>
<div className="docsCatalogEndpoints">
{group.endpoints.map((endpoint) => (
<div className="docsCatalogEndpoint" key={`${endpoint.method}-${endpoint.path}`}>
<MethodBadge method={endpoint.method} />
<code>{endpoint.path}</code>
<span>{endpoint.description}</span>
</div>
))}
</div>
</section>
))}
{visibleGroups.length === 0 && (
<div className="docsCatalogEmpty">{props.search}</div>
)}
</>
);
}
function OpenApiUsageNotes() {
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsStatusGrid">
<code>Base URL</code><p>SDK <code>https://ai.51easyai.com/api/v1</code>,请求资源路径时不要再次拼接 <code>/api/v1</code>。</p>
<code> / </code><p> Body <code>X-Async: true</code> <code>/tasks/{'{taskID}'}</code> </p>
<code></code><p>使 <code>modelType</code></p>
<code></code><p> public 使使 Gateway API Key</p>
</div>
</section>
);
}
function CompatibilityUsageNotes(props: { family?: PublicApiFamily }) {
if (props.family === 'kling') {
return (
<section className="paramCard docsDetailCard">
<header><h2> V1 / API 2.0 </h2></header>
<div className="docsStatusGrid">
<code>SDK Base URL</code><p> <code>https://ai.51easyai.com/api/v1/kling</code>,之后按原客户端继续请求 <code>/v1/...</code> 或 <code>/v2/...</code>。</p>
<code>O1</code><p>使 <code>kling-video-o1</code> <code>kling-o1</code> </p>
<code>3.0 Omni</code><p>使 <code>kling-v3-omni</code> <code>kling-3.0-omni</code> <code>kling-3-omni</code></p>
<code></code><p> Gateway API Key AK/SK </p>
</div>
</section>
);
}
if (props.family === 'gemini') {
return (
<section className="paramCard docsDetailCard">
<header><h2>Gemini </h2></header>
<div className="docsDetailContent">
<p><code>generateContent</code> Gemini Files <code>version</code> <code>v1</code> <code>v1beta</code> Gateway <code>/api/v1</code> </p>
</div>
</section>
);
}
if (props.family === 'volces') {
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsDetailContent">
<p> <code>/api/v3</code> </p>
</div>
</section>
);
}
if (props.family === 'serverMain') {
return (
<section className="paramCard docsDetailCard">
<header><h2>server-main </h2></header>
<div className="docsDetailContent">
<p> <code>submitted</code> <code>task_id</code> <code>/ai/result/{'{taskID}'}</code> </p>
</div>
</section>
);
}
return (
<section className="paramCard docsDetailCard">
<header><h2></h2></header>
<div className="docsDetailContent">
<p> SDK </p>
</div>
</section>
);
}
function OpsManagementSkillCard(props: { metadata: GatewaySkillBundleMetadata }) {
const modules = props.metadata.modules.map(skillModuleLabel).join('、');
return (
@@ -787,18 +981,63 @@ function supportsAsyncMode(section: ApiDocSection) {
|| section === 'videoGeneration';
}
function MethodBadge(props: { method: NonNullable<ApiDocItem['method']> }) {
return <Badge variant={props.method === 'GET' ? 'success' : 'warning'}>{props.method}</Badge>;
function MethodBadge(props: { method: PublicApiMethod }) {
const variant = props.method === 'GET'
? 'success'
: props.method === 'DELETE'
? 'destructive'
: props.method === 'PATCH'
? 'secondary'
: 'warning';
return <Badge variant={variant}>{props.method}</Badge>;
}
function groupDocs(items: typeof apiDocs) {
const groups = new Map<string, typeof apiDocs>();
function groupDocs(items: ApiDocItem[]) {
const groups = new Map<string, ApiDocItem[]>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
function groupDocsBySurface(items: ApiDocItem[]) {
const groups = new Map<PublicApiSurface, ApiDocItem[]>();
for (const item of items) {
groups.set(item.surface, [...(groups.get(item.surface) ?? []), item]);
}
return Array.from(groups, ([key, surfaceItems]) => ({ key, items: surfaceItems }));
}
function apiSurfaceTitle(surface: PublicApiSurface) {
return surface === 'open' ? '通用开放接口' : '兼容接口';
}
function guideMatchesSearch(item: ApiGuideItem, search: string) {
return searchText([item.title, item.lead, item.group], search);
}
function apiDocMatchesSearch(item: ApiDocItem, search: string) {
if (searchText([item.title, item.lead, item.path, item.group, apiSurfaceTitle(item.surface)], search)) {
return true;
}
if (!item.catalogSurface && !item.catalogFamily) return false;
return publicApiCatalogGroups
.filter((group) => (
(!item.catalogSurface || group.surface === item.catalogSurface)
&& (!item.catalogFamily || group.family === item.catalogFamily)
))
.some((group) => searchText([
group.title,
group.description,
...group.endpoints.flatMap((endpoint) => [endpoint.method, endpoint.path, endpoint.description]),
], search));
}
function searchText(values: Array<string | undefined>, search: string) {
if (!search) return true;
return values.some((value) => value?.toLowerCase().includes(search));
}
function defaultTaskForDoc(kind: TaskForm['kind'], current: TaskForm, taskResult: GatewayTask | null): TaskForm {
const next = defaultTaskForKind(kind, current);
if (kind === 'tasks.retrieve' && !next.taskId && taskResult && !taskResult.id.startsWith('docs-')) {