From cc3bbeccc2bed32e8d0a6fcd536aef095e86bdb2 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 15:42:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=8C=E5=96=84=20API=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E5=BC=82=E6=AD=A5=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api.test.ts | 38 ++ apps/web/src/api.ts | 26 + apps/web/src/lib/run-task.ts | 69 +- apps/web/src/pages/ApiDocsPage.test.tsx | 107 +++ apps/web/src/pages/ApiDocsPage.tsx | 840 +++++++++++++++++++++--- apps/web/src/routing.test.ts | 27 + apps/web/src/routing.ts | 23 +- apps/web/src/styles/api-docs.css | 119 ++++ apps/web/src/types.ts | 34 +- 9 files changed, 1182 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/pages/ApiDocsPage.test.tsx create mode 100644 apps/web/src/routing.test.ts diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index aedd9c7..0007300 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index eace7f8..4707e42 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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> { + return request>('/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(`/api/workspace/tasks/${taskId}`, { token }); } +export async function getAPITask(token: string, taskId: string): Promise { + return request(`/api/v1/tasks/${taskId}`, { token }); +} + export async function listTaskParamPreprocessing( token: string, taskId: string, diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index 9cf43c3..a5fd6ea 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -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) }; + } throw new Error(`Unsupported task kind: ${task.kind}`); } @@ -83,6 +123,18 @@ function compatibleTask(task: TaskForm, result: Record): Gatewa } function requestSnapshot(task: TaskForm): Record { + 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 { 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'; } diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx new file mode 100644 index 0000000..f3550ce --- /dev/null +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -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<object>'); + 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( + , + ); +} diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index c65cfde..9cb76bd 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react'; import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts'; import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react'; -import { Badge, Button, Select, Textarea } from '../components/ui'; +import { Badge, Button, Input, Select, Textarea } from '../components/ui'; import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api'; import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types'; import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared'; @@ -11,29 +11,58 @@ interface ApiDocItem { key: ApiDocSection; kind?: TaskKind; lead: string; - method: string; - path: string; + method?: 'GET' | 'POST'; + path?: string; title: string; } -const docs: ApiDocItem[] = [ +interface ApiDocParam { + children?: ApiDocParam[]; + name: string; + required?: boolean; + type: string; + value: string; +} + +type ApiGuideSection = Extract; + +interface ApiGuideItem { + group: '指南'; + key: ApiGuideSection; + lead: string; + title: string; +} + +export const apiDocs: ApiDocItem[] = [ { key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' }, + { key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' }, { key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embedding,API Key 需要 embedding 权限。' }, { key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API Key 需要 rerank 权限。' }, { key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' }, { key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/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: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' }, ]; -const guideItems = ['获取 Base URL 和 API Key', '通知设置-WebHook 参数介绍', '错误码', '测试模式']; +const guideItems: ApiGuideItem[] = [ + { key: 'guideBaseUrl', group: '指南', title: '获取 Base URL 和 API Key', lead: '确认当前部署环境的网关地址,创建 API Key,并使用 Bearer 鉴权调用公开接口。' }, + { key: 'guideWebhook', group: '指南', title: '通知设置-WebHook 参数介绍', lead: '了解任务进度 WebHook 的启用方式、回调结构、可靠投递机制和公开 API 的状态取回方式。' }, + { key: 'guideErrors', group: '指南', title: '错误码', lead: '根据 HTTP 状态、error.code、requestId 和异步任务错误字段快速定位请求失败原因。' }, + { key: 'guideTesting', group: '指南', title: '测试模式', lead: '使用 simulation 完整验证鉴权、路由、队列、限流、进度和结果归一,而不向真实供应商提交任务。' }, +]; const taskKindOptions = [ ['chat.completions', 'Chat'], + ['responses', 'Responses'], ['embeddings', '文本向量'], ['reranks', '重排序'], ['images.generations', '生图'], ['images.edits', '图像编辑'], + ['videos.generations', '视频生成'], + ['tasks.retrieve', '取回任务'], ] as const; const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = { @@ -64,18 +93,31 @@ export function ApiDocsPage(props: { onSubmitTask: (event: FormEvent) => void; onTaskFormChange: (value: TaskForm) => void; }) { - const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0]; + const activeGuide = guideItems.find((item) => item.key === props.activeDocSection); + const currentApiDoc = apiDocs.find((item) => item.key === props.activeDocSection) ?? (activeGuide ? undefined : apiDocs[0]); + const current = activeGuide ?? currentApiDoc ?? apiDocs[0]; const [opsSkillMetadata, setOpsSkillMetadata] = useState(defaultOpsSkillMetadata); - const isFileDoc = current.key === 'files'; + const isFileDoc = currentApiDoc?.key === 'files'; + const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve'; + const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode'; + const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path); const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); - const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]); + const bodyExample = useMemo( + () => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'), + [currentApiDoc?.key, props.taskForm], + ); + const runnerPath = currentApiDoc?.path + ? isTaskRetrieveDoc + ? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}') + : currentApiDoc.path + : ''; useEffect(() => { - if (current.kind && props.taskForm.kind !== current.kind) { - props.onTaskFormChange(defaultTaskForKind(current.kind, props.taskForm)); + if (currentApiDoc?.kind && props.taskForm.kind !== currentApiDoc.kind) { + props.onTaskFormChange(defaultTaskForDoc(currentApiDoc.kind, props.taskForm, props.taskResult)); } - }, [current.kind, props.taskForm.kind]); + }, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]); useEffect(() => { let active = true; @@ -92,6 +134,10 @@ export function ApiDocsPage(props: { }, []); function handleSubmit(event: FormEvent) { + if (!runnerAvailable) { + event.preventDefault(); + return; + } if (!props.canRun) { event.preventDefault(); props.onLogin(); @@ -102,13 +148,17 @@ export function ApiDocsPage(props: { function handleDocClick(item: ApiDocItem) { if (item.kind) { - props.onTaskFormChange(defaultTaskForKind(item.kind, props.taskForm)); + props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult)); } props.onDocSectionChange(item.key); } + function handleGuideClick(item: ApiGuideItem) { + props.onDocSectionChange(item.key); + } + function handleKindChange(kind: TaskKind) { - props.onTaskFormChange(defaultTaskForKind(kind, props.taskForm)); + props.onTaskFormChange(defaultTaskForDoc(kind, props.taskForm, props.taskResult)); const nextSection = docSectionForKind(kind); if (nextSection !== props.activeDocSection) { props.onDocSectionChange(nextSection); @@ -126,8 +176,15 @@ export function ApiDocsPage(props: { - ({ title }))} /> - {groupDocs(docs).map((group) => ( + ({ + active: item.key === props.activeDocSection, + title: item.title, + onClick: () => handleGuideClick(item), + }))} + /> + {groupDocs(apiDocs).map((group) => (

{current.group}

{current.title}

-
- {current.method} - {current.path} -
+ {currentApiDoc?.method && currentApiDoc.path && ( +
+ + {currentApiDoc.path} +
+ )}

{current.lead}

- + {isAsyncModeDoc ? ( + + ) : currentApiDoc ? ( + <> + {!isTaskRetrieveDoc && } -
-
-

Header 参数

- -
- - - -
+
+
+

Header 参数

+ +
+ {currentApiDoc.method !== 'GET' && } + + + {supportsAsyncMode(currentApiDoc.key) && ( + + )} +
-
-
-

Body 参数

- application/json -
- {isFileDoc ? ( - <> - - - - ) : ( - bodyParamRows(current.key).map((row) => ( - - )) - )} -
+
+
+

{isTaskRetrieveDoc ? 'Path 参数' : 'Body 参数'}

+ {!isTaskRetrieveDoc && {isFileDoc ? 'multipart/form-data' : 'application/json'}} +
+ {isTaskRetrieveDoc ? ( + + ) : isFileDoc ? ( + <> + + + + ) : ( + + )} +
+ + {isTaskRetrieveDoc && } + + + ) : activeGuide ? ( + + ) : null}