feat(web): 完善 API 文档与异步任务说明

This commit is contained in:
wangbo 2026-07-17 15:42:46 +08:00
parent 2d6c16fec0
commit cc3bbeccc2
9 changed files with 1182 additions and 101 deletions

View File

@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createResponse,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
getAPITask,
getCurrentUser,
getOpsManagementSkillMetadata,
OIDC_BROWSER_SESSION_CREDENTIAL,
@ -96,3 +98,39 @@ describe('Public Agent resources', () => {
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
describe('API documentation runner transports', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('runs Responses against the public OpenAI-compatible endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'resp-test', object: 'response' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await createResponse('sk-test', { model: 'gpt-test', input: 'hello', store: true, stream: false });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/v1/responses');
expect(init.method).toBe('POST');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer sk-test');
expect(JSON.parse(String(init.body))).toMatchObject({ model: 'gpt-test', input: 'hello', store: true, stream: false });
});
it('retrieves a task from the documented API path', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'task-123', status: 'running' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await getAPITask('sk-test', 'task-123');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/tasks/task-123');
expect(init.method).toBe('GET');
});
});

View File

@ -605,6 +605,26 @@ export async function createCompatibleChatCompletion(
});
}
export async function createResponse(
token: string,
input: {
model: string;
input: unknown;
instructions?: string;
previous_response_id?: string;
runMode?: string;
simulation?: boolean;
store?: boolean;
stream?: boolean;
},
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>('/v1/responses', {
body: input,
method: 'POST',
token,
});
}
export async function createEmbedding(
token: string,
input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean },
@ -818,6 +838,8 @@ export interface VideoGenerationParams {
mode?: 'std' | 'pro';
negative_prompt?: string;
cfg_scale?: number;
runMode?: string;
simulation?: boolean;
}
export async function createVideoGenerationTask(
@ -881,6 +903,10 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
}
export async function getAPITask(token: string, taskId: string): Promise<GatewayTask> {
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
}
export async function listTaskParamPreprocessing(
token: string,
taskId: string,

View File

@ -1,5 +1,14 @@
import type { GatewayTask } from '@easyai-ai-gateway/contracts';
import { createCompatibleChatCompletion, createEmbedding, createImageEditTask, createImageGenerationTask, createRerank } from '../api';
import {
createCompatibleChatCompletion,
createEmbedding,
createImageEditTask,
createImageGenerationTask,
createRerank,
createResponse,
createVideoGenerationTask,
getAPITask,
} from '../api';
import type { TaskForm } from '../types';
export interface RunTaskResponse {
@ -19,6 +28,19 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
});
return { localOnly: true, task: compatibleTask(task, result) };
}
if (task.kind === 'responses') {
const result = await createResponse(token, {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId,
runMode: 'simulation',
simulation: true,
store: true,
stream: false,
});
return { localOnly: true, task: compatibleTask(task, result) };
}
if (task.kind === 'embeddings') {
const result = await createEmbedding(token, {
model: task.model,
@ -60,6 +82,24 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
simulation: true,
});
}
if (task.kind === 'videos.generations') {
return createVideoGenerationTask(token, {
model: task.model,
content: [{ type: 'text', text: task.prompt }],
aspect_ratio: task.aspectRatio ?? '16:9',
resolution: task.resolution ?? '720p',
duration: task.duration ?? 5,
audio: task.outputAudio ?? true,
runMode: 'simulation',
simulation: true,
});
}
if (task.kind === 'tasks.retrieve') {
const taskId = task.taskId?.trim();
if (!taskId) throw new Error('请输入要取回的 Task ID');
const result = await getAPITask(token, taskId);
return { localOnly: true, task: compatibleTask(task, result as unknown as Record<string, unknown>) };
}
throw new Error(`Unsupported task kind: ${task.kind}`);
}
@ -83,6 +123,18 @@ function compatibleTask(task: TaskForm, result: Record<string, unknown>): Gatewa
}
function requestSnapshot(task: TaskForm): Record<string, unknown> {
if (task.kind === 'responses') {
return {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId,
runMode: 'simulation',
simulation: true,
store: true,
stream: false,
};
}
if (task.kind === 'embeddings') {
return {
model: task.model,
@ -102,6 +154,19 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
simulation: true,
};
}
if (task.kind === 'videos.generations') {
return {
model: task.model,
content: [{ type: 'text', text: task.prompt }],
aspect_ratio: task.aspectRatio ?? '16:9',
resolution: task.resolution ?? '720p',
duration: task.duration ?? 5,
audio: task.outputAudio ?? true,
runMode: 'simulation',
simulation: true,
};
}
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
return {
model: task.model,
messages: [{ role: 'user', content: task.prompt }],
@ -133,5 +198,7 @@ function modelTypeForKind(kind: TaskForm['kind']) {
if (kind === 'reranks') return 'text_rerank';
if (kind === 'images.generations') return 'image_generate';
if (kind === 'images.edits') return 'image_edit';
if (kind === 'videos.generations') return 'video_generate';
if (kind === 'tasks.retrieve') return 'task';
return 'text_generate';
}

View File

@ -0,0 +1,107 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type { ApiDocSection, TaskForm } from '../types';
import { ApiDocsPage } from './ApiDocsPage';
describe('ApiDocsPage extended task documentation', () => {
it.each([
['guideBaseUrl', '前往创建 API Key'],
['guideWebhook', 'TASK_PROGRESS_CALLBACK_ENABLED'],
['guideErrors', '400 invalid_parameter'],
['guideTesting', 'simulated=true'],
] as const)('renders the clickable guide page %s', (section, expectedContent) => {
const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('data-active="true"');
expect(html).toContain(expectedContent);
expect(html).toContain('指南导航');
expect(html).not.toContain('/v1/chat/completions');
});
it('documents the OpenAI-compatible Responses request and continuity fields', () => {
const html = renderDocs('responses', { kind: 'responses', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('/v1/responses');
expect(html).toContain('previous_response_id');
expect(html).toContain('X-Async');
expect(html).toContain('连续对话与状态归属');
});
it('documents video generation parameters and async retrieval guidance', () => {
const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' });
expect(html).toContain('/api/v1/videos/generations');
expect(html).toContain('aspect_ratio');
expect(html).toContain('reference_image');
expect(html).toContain('任务取回接口');
});
it('documents async mode as a body-independent capability', () => {
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('X-Async');
expect(html).toContain('原请求 Header');
expect(html).toContain('同步与异步的区别');
expect(html).toContain('异步受理响应');
expect(html).not.toContain('/api/v1/videos/generations');
expect(html).not.toContain('Body 参数');
expect(html).not.toContain('视频模型 ID');
});
it.each(['chat', 'responses', 'embeddings', 'reranks', 'imageGeneration', 'imageEdit', 'videoGeneration'] as const)(
'shows the shared X-Async switch on the %s task API',
(section) => {
const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('X-Async');
},
);
it('renders nested objects and object arrays recursively', () => {
const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' });
expect(html).toContain('array&lt;object&gt;');
expect(html).toContain('data-depth="1"');
expect(html).toContain('data-depth="4"');
expect(html).toContain('inline_element');
expect(html).toContain('refer_images');
expect(html).toContain('slot_key');
});
it('renders task retrieval as GET with a path parameter instead of a request body', () => {
const html = renderDocs('taskRetrieve', { kind: 'tasks.retrieve', model: 'task', prompt: '', taskId: 'task-123' });
expect(html).toContain('GET');
expect(html).toContain('/api/v1/tasks/task-123');
expect(html).toContain('Path 参数');
expect(html).toContain('响应 Body');
expect(html).toContain('result');
expect(html).toContain('billingSummary');
expect(html).toContain('attempts');
expect(html).toContain('成功响应示例');
expect(html).toContain('Task ID');
expect(html).not.toContain('请求 Body');
});
});
function renderDocs(activeDocSection: ApiDocSection, taskForm: TaskForm) {
return renderToStaticMarkup(
<ApiDocsPage
activeDocSection={activeDocSection}
apiKeySecretsById={{}}
apiKeys={[]}
canRun
coreMessage=""
coreState="idle"
selectedApiKeyId=""
taskForm={taskForm}
taskResult={null}
onApiKeyChange={vi.fn()}
onCreateApiKey={vi.fn()}
onDocSectionChange={vi.fn()}
onLogin={vi.fn()}
onSubmitTask={vi.fn()}
onTaskFormChange={vi.fn()}
/>,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { parseAppRoute, pathForApiDocSection } from './routing';
describe('API documentation routes', () => {
it.each([
['guideBaseUrl', '/docs/auth'],
['guideWebhook', '/docs/webhook'],
['guideErrors', '/docs/errors'],
['guideTesting', '/docs/testing'],
['responses', '/docs/responses'],
['videoGeneration', '/docs/videos/generations'],
['asyncMode', '/docs/async'],
['taskRetrieve', '/docs/tasks/retrieve'],
] as const)('maps %s to a stable documentation URL', (section, path) => {
expect(pathForApiDocSection(section)).toBe(path);
expect(parseAppRoute(path).apiDocSection).toBe(section);
});
it.each([
['/docs/playground', 'guideTesting'],
['/docs/api/responses', 'responses'],
['/docs/api/videos', 'videoGeneration'],
['/docs/api/tasks', 'taskRetrieve'],
] as const)('keeps the legacy documentation URL %s working', (path, section) => {
expect(parseAppRoute(path).apiDocSection).toBe(section);
});
});

View File

@ -43,11 +43,19 @@ const adminPaths: Record<AdminSection, string> = {
};
const docsPaths: Record<ApiDocSection, string> = {
guideBaseUrl: '/docs/auth',
guideWebhook: '/docs/webhook',
guideErrors: '/docs/errors',
guideTesting: '/docs/testing',
chat: '/docs/chat',
responses: '/docs/responses',
embeddings: '/docs/embeddings',
reranks: '/docs/reranks',
imageGeneration: '/docs/images/generations',
imageEdit: '/docs/images/edits',
videoGeneration: '/docs/videos/generations',
asyncMode: '/docs/async',
taskRetrieve: '/docs/tasks/retrieve',
pricing: '/docs/pricing',
files: '/docs/files',
};
@ -63,8 +71,9 @@ const adminSections = reverseMap(adminPaths);
const docsSections = reverseMap(docsPaths);
const playgroundSections = reverseMap(playgroundPaths);
export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState {
const url = new URL(input, window.location.origin);
export function parseAppRoute(input = currentLocationPath()): AppRouteState {
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
const url = new URL(input, origin);
const path = normalizePath(url.pathname);
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
if (path === '/') return { ...defaultRouteState };
@ -84,6 +93,11 @@ export function parseAppRoute(input = `${window.location.pathname}${window.locat
return { ...defaultRouteState };
}
function currentLocationPath() {
if (typeof window === 'undefined') return '/';
return `${window.location.pathname}${window.location.search}`;
}
export function pathForPage(page: PageKey, route: AppRouteState): string {
if (page === 'playground') return pathForPlaygroundMode(route.playgroundMode);
if (page === 'models') return '/models';
@ -135,11 +149,14 @@ function parseAdminSection(path: string): AdminSection {
function parseDocSection(path: string): ApiDocSection {
if (path === '/docs') return 'chat';
if (path === '/docs/playground') return 'guideTesting';
if (path === '/docs/api/chat') return 'chat';
if (path === '/docs/api/responses') return 'responses';
if (path === '/docs/api/embeddings') return 'embeddings';
if (path === '/docs/api/reranks') return 'reranks';
if (path === '/docs/api/media') return 'imageGeneration';
if (path === '/docs/playground') return 'chat';
if (path === '/docs/api/videos') return 'videoGeneration';
if (path === '/docs/api/tasks') return 'taskRetrieve';
return docsSections[path] ?? 'chat';
}

View File

@ -218,6 +218,7 @@
.paramRow {
display: grid;
grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
align-items: start;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
@ -225,11 +226,91 @@
font-size: 0.8125rem;
}
.paramRow[data-nested='true'] {
background: #fbfcfe;
}
.paramName {
position: relative;
min-width: 0;
}
.paramName code {
overflow-wrap: anywhere;
}
.paramTreeMarker {
position: absolute;
top: -13px;
width: 12px;
height: 23px;
border-bottom: 1px solid #cfd7e3;
border-left: 1px solid #cfd7e3;
border-radius: 0 0 0 4px;
}
.paramRow em {
color: #a16207;
font-style: normal;
}
.docsDetailContent {
display: grid;
gap: 12px;
padding: 16px;
color: var(--text-soft);
font-size: 0.875rem;
line-height: 1.7;
}
.docsDetailContent p {
margin: 0;
}
.docsDetailContent pre {
overflow: auto;
margin: 0;
padding: 14px;
border-radius: 8px;
background: #f7f8fa;
color: var(--text-normal);
font-size: 0.75rem;
line-height: 1.6;
}
.docsSteps {
display: grid;
gap: 8px;
margin: 0;
padding-left: 20px;
}
.docsGuideActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.docsStatusGrid {
display: grid;
grid-template-columns: minmax(190px, auto) minmax(0, 1fr);
gap: 0;
}
.docsStatusGrid > * {
margin: 0;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsStatusGrid > code {
color: var(--text-normal);
}
.docsRunner {
border-left: 1px solid var(--border);
background: #fff;
@ -241,6 +322,40 @@
padding: 0 16px 16px;
}
.docsRunnerUnavailable {
padding: 14px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--surface-muted);
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsGuideAside header {
display: flex;
align-items: center;
min-height: 58px;
}
.docsGuideAside > div {
display: grid;
gap: 10px;
padding: 18px 16px;
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsGuideAside span,
.docsGuideAside p {
margin: 0;
}
.docsGuideAside strong {
color: var(--text-normal);
}
.runnerResult {
display: grid;
gap: 12px;
@ -279,6 +394,10 @@
grid-template-columns: 1fr;
}
.docsStatusGrid {
grid-template-columns: 1fr;
}
.agentResourceCard {
grid-template-columns: 1fr;
}

View File

@ -1,10 +1,33 @@
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export type AuthMode = 'login' | 'register' | 'external';
export type TaskKind = 'chat.completions' | 'embeddings' | 'reranks' | 'images.generations' | 'images.edits';
export type TaskKind =
| 'chat.completions'
| 'responses'
| 'embeddings'
| 'reranks'
| 'images.generations'
| 'images.edits'
| 'videos.generations'
| 'tasks.retrieve';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection = 'chat' | 'embeddings' | 'reranks' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
export type ApiDocSection =
| 'guideBaseUrl'
| 'guideWebhook'
| 'guideErrors'
| 'guideTesting'
| 'chat'
| 'responses'
| 'embeddings'
| 'reranks'
| 'imageGeneration'
| 'imageEdit'
| 'videoGeneration'
| 'asyncMode'
| 'taskRetrieve'
| 'pricing'
| 'files';
export type AdminSection =
| 'overview'
| 'globalModels'
@ -37,10 +60,17 @@ export interface TaskForm {
kind: TaskKind;
model: string;
prompt: string;
aspectRatio?: string;
dimensions?: number;
documents?: string;
duration?: number;
image?: string;
instructions?: string;
mask?: string;
outputAudio?: boolean;
previousResponseId?: string;
resolution?: string;
taskId?: string;
topN?: number;
}