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 { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
createResponse,
deleteOIDCBrowserSession, deleteOIDCBrowserSession,
GatewayApiError, GatewayApiError,
gatewayErrorMessage, gatewayErrorMessage,
getAPITask,
getCurrentUser, getCurrentUser,
getOpsManagementSkillMetadata, getOpsManagementSkillMetadata,
OIDC_BROWSER_SESSION_CREDENTIAL, OIDC_BROWSER_SESSION_CREDENTIAL,
@ -96,3 +98,39 @@ describe('Public Agent resources', () => {
expect(new Headers(init.headers).has('Authorization')).toBe(false); 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( export async function createEmbedding(
token: string, token: string,
input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean }, input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean },
@ -818,6 +838,8 @@ export interface VideoGenerationParams {
mode?: 'std' | 'pro'; mode?: 'std' | 'pro';
negative_prompt?: string; negative_prompt?: string;
cfg_scale?: number; cfg_scale?: number;
runMode?: string;
simulation?: boolean;
} }
export async function createVideoGenerationTask( 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 }); 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( export async function listTaskParamPreprocessing(
token: string, token: string,
taskId: string, taskId: string,

View File

@ -1,5 +1,14 @@
import type { GatewayTask } from '@easyai-ai-gateway/contracts'; 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'; import type { TaskForm } from '../types';
export interface RunTaskResponse { export interface RunTaskResponse {
@ -19,6 +28,19 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
}); });
return { localOnly: true, task: compatibleTask(task, result) }; 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') { if (task.kind === 'embeddings') {
const result = await createEmbedding(token, { const result = await createEmbedding(token, {
model: task.model, model: task.model,
@ -60,6 +82,24 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
simulation: true, 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}`); 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> { 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') { if (task.kind === 'embeddings') {
return { return {
model: task.model, model: task.model,
@ -102,6 +154,19 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
simulation: true, 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 { return {
model: task.model, model: task.model,
messages: [{ role: 'user', content: task.prompt }], messages: [{ role: 'user', content: task.prompt }],
@ -133,5 +198,7 @@ function modelTypeForKind(kind: TaskForm['kind']) {
if (kind === 'reranks') return 'text_rerank'; if (kind === 'reranks') return 'text_rerank';
if (kind === 'images.generations') return 'image_generate'; if (kind === 'images.generations') return 'image_generate';
if (kind === 'images.edits') return 'image_edit'; if (kind === 'images.edits') return 'image_edit';
if (kind === 'videos.generations') return 'video_generate';
if (kind === 'tasks.retrieve') return 'task';
return 'text_generate'; 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> = { const docsPaths: Record<ApiDocSection, string> = {
guideBaseUrl: '/docs/auth',
guideWebhook: '/docs/webhook',
guideErrors: '/docs/errors',
guideTesting: '/docs/testing',
chat: '/docs/chat', chat: '/docs/chat',
responses: '/docs/responses',
embeddings: '/docs/embeddings', embeddings: '/docs/embeddings',
reranks: '/docs/reranks', reranks: '/docs/reranks',
imageGeneration: '/docs/images/generations', imageGeneration: '/docs/images/generations',
imageEdit: '/docs/images/edits', imageEdit: '/docs/images/edits',
videoGeneration: '/docs/videos/generations',
asyncMode: '/docs/async',
taskRetrieve: '/docs/tasks/retrieve',
pricing: '/docs/pricing', pricing: '/docs/pricing',
files: '/docs/files', files: '/docs/files',
}; };
@ -63,8 +71,9 @@ const adminSections = reverseMap(adminPaths);
const docsSections = reverseMap(docsPaths); const docsSections = reverseMap(docsPaths);
const playgroundSections = reverseMap(playgroundPaths); const playgroundSections = reverseMap(playgroundPaths);
export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState { export function parseAppRoute(input = currentLocationPath()): AppRouteState {
const url = new URL(input, window.location.origin); const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
const url = new URL(input, origin);
const path = normalizePath(url.pathname); const path = normalizePath(url.pathname);
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams); const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
if (path === '/') return { ...defaultRouteState }; if (path === '/') return { ...defaultRouteState };
@ -84,6 +93,11 @@ export function parseAppRoute(input = `${window.location.pathname}${window.locat
return { ...defaultRouteState }; return { ...defaultRouteState };
} }
function currentLocationPath() {
if (typeof window === 'undefined') return '/';
return `${window.location.pathname}${window.location.search}`;
}
export function pathForPage(page: PageKey, route: AppRouteState): string { export function pathForPage(page: PageKey, route: AppRouteState): string {
if (page === 'playground') return pathForPlaygroundMode(route.playgroundMode); if (page === 'playground') return pathForPlaygroundMode(route.playgroundMode);
if (page === 'models') return '/models'; if (page === 'models') return '/models';
@ -135,11 +149,14 @@ function parseAdminSection(path: string): AdminSection {
function parseDocSection(path: string): ApiDocSection { function parseDocSection(path: string): ApiDocSection {
if (path === '/docs') return 'chat'; if (path === '/docs') return 'chat';
if (path === '/docs/playground') return 'guideTesting';
if (path === '/docs/api/chat') return 'chat'; 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/embeddings') return 'embeddings';
if (path === '/docs/api/reranks') return 'reranks'; if (path === '/docs/api/reranks') return 'reranks';
if (path === '/docs/api/media') return 'imageGeneration'; 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'; return docsSections[path] ?? 'chat';
} }

View File

@ -218,6 +218,7 @@
.paramRow { .paramRow {
display: grid; display: grid;
grid-template-columns: 150px 110px minmax(0, 1fr) 50px; grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
align-items: start;
gap: 12px; gap: 12px;
padding: 13px 16px; padding: 13px 16px;
border-bottom: 1px solid #f0f2f5; border-bottom: 1px solid #f0f2f5;
@ -225,11 +226,91 @@
font-size: 0.8125rem; 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 { .paramRow em {
color: #a16207; color: #a16207;
font-style: normal; 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 { .docsRunner {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
background: #fff; background: #fff;
@ -241,6 +322,40 @@
padding: 0 16px 16px; 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 { .runnerResult {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -279,6 +394,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.docsStatusGrid {
grid-template-columns: 1fr;
}
.agentResourceCard { .agentResourceCard {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -1,10 +1,33 @@
export type LoadState = 'idle' | 'loading' | 'ready' | 'error'; export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export type AuthMode = 'login' | 'register' | 'external'; 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 PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PlaygroundMode = 'chat' | 'image' | 'video'; export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions'; 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 = export type AdminSection =
| 'overview' | 'overview'
| 'globalModels' | 'globalModels'
@ -37,10 +60,17 @@ export interface TaskForm {
kind: TaskKind; kind: TaskKind;
model: string; model: string;
prompt: string; prompt: string;
aspectRatio?: string;
dimensions?: number; dimensions?: number;
documents?: string; documents?: string;
duration?: number;
image?: string; image?: string;
instructions?: string;
mask?: string; mask?: string;
outputAudio?: boolean;
previousResponseId?: string;
resolution?: string;
taskId?: string;
topN?: number; topN?: number;
} }