feat(web): 完善 API 文档与异步任务说明
This commit is contained in:
parent
2d6c16fec0
commit
cc3bbeccc2
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
107
apps/web/src/pages/ApiDocsPage.test.tsx
Normal file
107
apps/web/src/pages/ApiDocsPage.test.tsx
Normal 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<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(
|
||||||
|
<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()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts';
|
||||||
import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react';
|
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 { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
|
||||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
|
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
|
||||||
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
||||||
@ -11,29 +11,58 @@ interface ApiDocItem {
|
|||||||
key: ApiDocSection;
|
key: ApiDocSection;
|
||||||
kind?: TaskKind;
|
kind?: TaskKind;
|
||||||
lead: string;
|
lead: string;
|
||||||
method: string;
|
method?: 'GET' | 'POST';
|
||||||
path: string;
|
path?: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docs: ApiDocItem[] = [
|
interface ApiDocParam {
|
||||||
|
children?: ApiDocParam[];
|
||||||
|
name: string;
|
||||||
|
required?: boolean;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiGuideSection = Extract<ApiDocSection, 'guideBaseUrl' | 'guideWebhook' | 'guideErrors' | 'guideTesting'>;
|
||||||
|
|
||||||
|
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: '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: '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: '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: '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: '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: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
|
||||||
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
|
{ 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 = [
|
const taskKindOptions = [
|
||||||
['chat.completions', 'Chat'],
|
['chat.completions', 'Chat'],
|
||||||
|
['responses', 'Responses'],
|
||||||
['embeddings', '文本向量'],
|
['embeddings', '文本向量'],
|
||||||
['reranks', '重排序'],
|
['reranks', '重排序'],
|
||||||
['images.generations', '生图'],
|
['images.generations', '生图'],
|
||||||
['images.edits', '图像编辑'],
|
['images.edits', '图像编辑'],
|
||||||
|
['videos.generations', '视频生成'],
|
||||||
|
['tasks.retrieve', '取回任务'],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
|
const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
|
||||||
@ -64,18 +93,31 @@ export function ApiDocsPage(props: {
|
|||||||
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
onTaskFormChange: (value: TaskForm) => 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<GatewaySkillBundleMetadata>(defaultOpsSkillMetadata);
|
const [opsSkillMetadata, setOpsSkillMetadata] = useState<GatewaySkillBundleMetadata>(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 apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
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(() => {
|
useEffect(() => {
|
||||||
if (current.kind && props.taskForm.kind !== current.kind) {
|
if (currentApiDoc?.kind && props.taskForm.kind !== currentApiDoc.kind) {
|
||||||
props.onTaskFormChange(defaultTaskForKind(current.kind, props.taskForm));
|
props.onTaskFormChange(defaultTaskForDoc(currentApiDoc.kind, props.taskForm, props.taskResult));
|
||||||
}
|
}
|
||||||
}, [current.kind, props.taskForm.kind]);
|
}, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@ -92,6 +134,10 @@ export function ApiDocsPage(props: {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
if (!runnerAvailable) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!props.canRun) {
|
if (!props.canRun) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
props.onLogin();
|
props.onLogin();
|
||||||
@ -102,13 +148,17 @@ export function ApiDocsPage(props: {
|
|||||||
|
|
||||||
function handleDocClick(item: ApiDocItem) {
|
function handleDocClick(item: ApiDocItem) {
|
||||||
if (item.kind) {
|
if (item.kind) {
|
||||||
props.onTaskFormChange(defaultTaskForKind(item.kind, props.taskForm));
|
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
|
||||||
}
|
}
|
||||||
props.onDocSectionChange(item.key);
|
props.onDocSectionChange(item.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGuideClick(item: ApiGuideItem) {
|
||||||
|
props.onDocSectionChange(item.key);
|
||||||
|
}
|
||||||
|
|
||||||
function handleKindChange(kind: TaskKind) {
|
function handleKindChange(kind: TaskKind) {
|
||||||
props.onTaskFormChange(defaultTaskForKind(kind, props.taskForm));
|
props.onTaskFormChange(defaultTaskForDoc(kind, props.taskForm, props.taskResult));
|
||||||
const nextSection = docSectionForKind(kind);
|
const nextSection = docSectionForKind(kind);
|
||||||
if (nextSection !== props.activeDocSection) {
|
if (nextSection !== props.activeDocSection) {
|
||||||
props.onDocSectionChange(nextSection);
|
props.onDocSectionChange(nextSection);
|
||||||
@ -126,8 +176,15 @@ export function ApiDocsPage(props: {
|
|||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
<input placeholder="搜索" />
|
<input placeholder="搜索" />
|
||||||
</label>
|
</label>
|
||||||
<DocsGroup title="指南" items={guideItems.map((title) => ({ title }))} />
|
<DocsGroup
|
||||||
{groupDocs(docs).map((group) => (
|
title="指南"
|
||||||
|
items={guideItems.map((item) => ({
|
||||||
|
active: item.key === props.activeDocSection,
|
||||||
|
title: item.title,
|
||||||
|
onClick: () => handleGuideClick(item),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{groupDocs(apiDocs).map((group) => (
|
||||||
<DocsGroup
|
<DocsGroup
|
||||||
key={group.title}
|
key={group.title}
|
||||||
title={group.title}
|
title={group.title}
|
||||||
@ -144,54 +201,70 @@ export function ApiDocsPage(props: {
|
|||||||
<main className="docsArticle">
|
<main className="docsArticle">
|
||||||
<p className="eyebrow">{current.group}</p>
|
<p className="eyebrow">{current.group}</p>
|
||||||
<h1>{current.title}</h1>
|
<h1>{current.title}</h1>
|
||||||
|
{currentApiDoc?.method && currentApiDoc.path && (
|
||||||
<div className="endpointBar">
|
<div className="endpointBar">
|
||||||
<Badge variant="warning">{current.method}</Badge>
|
<MethodBadge method={currentApiDoc.method} />
|
||||||
<code>{current.path}</code>
|
<code>{currentApiDoc.path}</code>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<p className="docsLead">{current.lead}</p>
|
<p className="docsLead">{current.lead}</p>
|
||||||
|
|
||||||
<OpsManagementSkillCard metadata={opsSkillMetadata} />
|
{isAsyncModeDoc ? (
|
||||||
|
<DocDetails section="asyncMode" />
|
||||||
|
) : currentApiDoc ? (
|
||||||
|
<>
|
||||||
|
{!isTaskRetrieveDoc && <OpsManagementSkillCard metadata={opsSkillMetadata} />}
|
||||||
|
|
||||||
<section className="paramCard">
|
<section className="paramCard">
|
||||||
<header>
|
<header>
|
||||||
<h2>Header 参数</h2>
|
<h2>Header 参数</h2>
|
||||||
<Button type="button" variant="secondary" size="sm">生成代码</Button>
|
<Button type="button" variant="secondary" size="sm">生成代码</Button>
|
||||||
</header>
|
</header>
|
||||||
<ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />
|
{currentApiDoc.method !== 'GET' && <ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />}
|
||||||
<ParamRow name="Accept" type="string" required value="application/json" />
|
<ParamRow name="Accept" type="string" required value="application/json" />
|
||||||
<ParamRow name="Authorization" type="string" required value="Bearer {{YOUR_API_KEY}},支持本地 API Key;管理接口仍使用 JWT。向量需 embedding 权限,重排序需 rerank 权限。" />
|
<ParamRow name="Authorization" type="string" required value={authorizationDescription(currentApiDoc.key)} />
|
||||||
|
{supportsAsyncMode(currentApiDoc.key) && (
|
||||||
|
<ParamRow name="X-Async" type="boolean" value="设为 true 后立即返回 202 和 taskId;省略时按接口默认方式执行。" />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="paramCard">
|
<section className="paramCard">
|
||||||
<header>
|
<header>
|
||||||
<h2>Body 参数</h2>
|
<h2>{isTaskRetrieveDoc ? 'Path 参数' : 'Body 参数'}</h2>
|
||||||
<Badge variant="outline">application/json</Badge>
|
{!isTaskRetrieveDoc && <Badge variant="outline">{isFileDoc ? 'multipart/form-data' : 'application/json'}</Badge>}
|
||||||
</header>
|
</header>
|
||||||
{isFileDoc ? (
|
{isTaskRetrieveDoc ? (
|
||||||
|
<ParamRow name="taskID" type="string" required value="异步提交响应中的 taskId,也可直接使用 next.detail 返回的地址。" />
|
||||||
|
) : isFileDoc ? (
|
||||||
<>
|
<>
|
||||||
<ParamRow name="file" type="file" required value="multipart 文件字段" />
|
<ParamRow name="file" type="file" required value="multipart 文件字段" />
|
||||||
<ParamRow name="source" type="string" value="上传来源标记" />
|
<ParamRow name="source" type="string" value="上传来源标记" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
bodyParamRows(current.key).map((row) => (
|
<ParamRows rows={bodyParamRows(currentApiDoc.key)} />
|
||||||
<ParamRow key={row.name} {...row} />
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isTaskRetrieveDoc && <TaskResponseSchema />}
|
||||||
|
<DocDetails section={currentApiDoc.key} />
|
||||||
|
</>
|
||||||
|
) : activeGuide ? (
|
||||||
|
<GuideDetails onCreateApiKey={props.onCreateApiKey} section={activeGuide.key} />
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="docsRunner">
|
<aside className="docsRunner">
|
||||||
<form onSubmit={handleSubmit}>
|
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
|
||||||
<header>
|
<header>
|
||||||
<strong>在线运行</strong>
|
<strong>在线运行</strong>
|
||||||
<Button type="submit" size="sm" disabled={props.canRun && props.coreState === 'loading'}>
|
<Button type="submit" size="sm" disabled={!runnerAvailable || (props.canRun && props.coreState === 'loading')}>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
{props.canRun ? '发送' : '登录'}
|
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="runnerEndpoint">
|
<div className="runnerEndpoint">
|
||||||
<Badge variant="warning">POST</Badge>
|
<MethodBadge method={currentApiDoc.method} />
|
||||||
<span>{current.path}</span>
|
<span>{runnerPath}</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="shLabel">
|
<label className="shLabel">
|
||||||
API Key
|
API Key
|
||||||
@ -211,6 +284,8 @@ export function ApiDocsPage(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{runnerAvailable ? (
|
||||||
|
<>
|
||||||
<label className="shLabel">
|
<label className="shLabel">
|
||||||
能力
|
能力
|
||||||
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
|
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
|
||||||
@ -219,26 +294,50 @@ export function ApiDocsPage(props: {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
{isTaskRetrieveDoc ? (
|
||||||
|
<label className="shLabel">
|
||||||
|
Task ID
|
||||||
|
<Input
|
||||||
|
placeholder="输入异步提交返回的 taskId"
|
||||||
|
value={props.taskForm.taskId ?? ''}
|
||||||
|
onChange={(event) => props.onTaskFormChange({ ...props.taskForm, taskId: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
<label className="shLabel">
|
<label className="shLabel">
|
||||||
请求 Body
|
请求 Body
|
||||||
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
|
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
|
||||||
<Play size={15} />
|
<Play size={15} />
|
||||||
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</>
|
||||||
<section className="runnerResult">
|
) : (
|
||||||
|
<div className="docsRunnerUnavailable">该接口暂未接入在线运行,请使用上方 Swagger 定义生成调用代码。</div>
|
||||||
|
)}
|
||||||
|
</form> : (
|
||||||
|
<section className="docsGuideAside">
|
||||||
|
<header><strong>{isAsyncModeDoc ? '能力说明' : '指南导航'}</strong></header>
|
||||||
|
<div>
|
||||||
|
<span>当前章节</span>
|
||||||
|
<strong>{current.title}</strong>
|
||||||
|
<p>{isAsyncModeDoc ? '异步模式不改变原接口的请求参数,只改变任务提交后的等待和响应方式。' : '使用左侧目录切换其他指南或 API 接口,浏览器前进、后退可保持当前章节。'}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc && <section className="runnerResult">
|
||||||
<strong>返回结果</strong>
|
<strong>返回结果</strong>
|
||||||
{props.taskResult ? (
|
{props.taskResult ? (
|
||||||
<pre>{JSON.stringify(props.taskResult.result ?? props.taskResult, null, 2)}</pre>
|
<pre>{JSON.stringify(isTaskRetrieveDoc ? props.taskResult : props.taskResult.result ?? props.taskResult, null, 2)}</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="emptyState">
|
<div className="emptyState">
|
||||||
<span>点击发送获取返回结果</span>
|
<span>点击发送获取返回结果</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.coreMessage && <p>{props.coreMessage}</p>}
|
{props.coreMessage && <p>{props.coreMessage}</p>}
|
||||||
</section>
|
</section>}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -303,7 +402,7 @@ function skillModuleLabel(module: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DocsGroup(props: {
|
function DocsGroup(props: {
|
||||||
items: Array<{ active?: boolean; method?: string; onClick?: () => void; title: string }>;
|
items: Array<{ active?: boolean; method?: ApiDocItem['method']; onClick?: () => void; title: string }>;
|
||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -312,17 +411,41 @@ function DocsGroup(props: {
|
|||||||
{props.items.map((item) => (
|
{props.items.map((item) => (
|
||||||
<button type="button" data-active={item.active} key={item.title} onClick={item.onClick}>
|
<button type="button" data-active={item.active} key={item.title} onClick={item.onClick}>
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{item.method && <Badge variant="warning">{item.method}</Badge>}
|
{item.method && <MethodBadge method={item.method} />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParamRow(props: { name: string; required?: boolean; type: string; value: string }) {
|
function ParamRows(props: { depth?: number; parentPath?: string; rows: ApiDocParam[] }) {
|
||||||
|
const depth = props.depth ?? 0;
|
||||||
|
const parentPath = props.parentPath ?? '';
|
||||||
|
|
||||||
|
return props.rows.map((row) => {
|
||||||
|
const path = parentPath ? `${parentPath}.${row.name}` : row.name;
|
||||||
return (
|
return (
|
||||||
<div className="paramRow">
|
<Fragment key={path}>
|
||||||
|
<ParamRow {...row} depth={depth} />
|
||||||
|
{row.children?.length ? (
|
||||||
|
<ParamRows depth={depth + 1} parentPath={path} rows={row.children} />
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParamRow(props: ApiDocParam & { depth?: number }) {
|
||||||
|
const depth = props.depth ?? 0;
|
||||||
|
const nameStyle: CSSProperties = depth ? { paddingLeft: `${depth * 20}px` } : {};
|
||||||
|
const markerStyle: CSSProperties = depth ? { left: `${(depth - 1) * 20 + 3}px` } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paramRow" data-depth={depth} data-nested={depth > 0 ? 'true' : undefined}>
|
||||||
|
<div className="paramName" style={nameStyle}>
|
||||||
|
{depth > 0 && <span aria-hidden="true" className="paramTreeMarker" style={markerStyle} />}
|
||||||
<code>{props.name}</code>
|
<code>{props.name}</code>
|
||||||
|
</div>
|
||||||
<span>{props.type}</span>
|
<span>{props.type}</span>
|
||||||
<p>{props.value}</p>
|
<p>{props.value}</p>
|
||||||
<em>{props.required ? '必需' : '可选'}</em>
|
<em>{props.required ? '必需' : '可选'}</em>
|
||||||
@ -330,16 +453,284 @@ function ParamRow(props: { name: string; required?: boolean; type: string; value
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDocs(items: typeof docs) {
|
function GuideDetails(props: { onCreateApiKey: () => void; section: ApiGuideSection }) {
|
||||||
const groups = new Map<string, typeof docs>();
|
if (props.section === 'guideBaseUrl') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GuideSection title="1. 确认 Base URL">
|
||||||
|
<p>Base URL 由当前 Gateway 部署环境提供。将接口文档中的路径拼接到 Base URL 后调用,不要重复添加末尾斜杠。</p>
|
||||||
|
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
|
||||||
|
</GuideSection>
|
||||||
|
<GuideSection title="2. 创建并使用 API Key">
|
||||||
|
<p>登录后在“用户工作台 → API Key”创建 Key,并为它分配实际需要的 chat、embedding、rerank、image 或 video 权限。密钥只在创建或重置时完整展示,请立即安全保存。</p>
|
||||||
|
<div className="docsGuideActions">
|
||||||
|
<Button type="button" size="sm" onClick={props.onCreateApiKey}>
|
||||||
|
<KeyRound size={14} />
|
||||||
|
前往创建 API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre>{`Authorization: Bearer sk-...\nContent-Type: application/json\nAccept: application/json`}</pre>
|
||||||
|
</GuideSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.section === 'guideWebhook') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GuideSection title="任务进度 WebHook">
|
||||||
|
<p>Gateway 的任务进度 WebHook 由部署管理员统一配置,用于把任务事件可靠投递到业务服务;它不是公开请求中可任意覆盖的回调地址。</p>
|
||||||
|
<pre>{`TASK_PROGRESS_CALLBACK_ENABLED=true\nTASK_PROGRESS_CALLBACK_URL=https://service.example.com/internal/task-progress`}</pre>
|
||||||
|
<p>每次状态变化会先写入任务事件和 callback outbox,再异步投递。失败不会阻塞任务执行,并会按 outbox 策略重试。</p>
|
||||||
|
</GuideSection>
|
||||||
|
<GuideSection title="回调 Body">
|
||||||
|
<pre>{`{\n "taskId": "9f4d8f3d-...",\n "seq": 3,\n "eventType": "progress",\n "status": "running",\n "phase": "generating",\n "progress": 0.6,\n "message": "任务处理中",\n "payload": {},\n "simulated": false,\n "createdAt": "2026-07-17T07:00:00Z"\n}`}</pre>
|
||||||
|
<p>公开 API 客户端无需依赖 WebHook:异步提交后可直接使用 <code>next.detail</code> 查询任务,或使用 <code>next.events</code> 订阅进度事件。</p>
|
||||||
|
</GuideSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.section === 'guideErrors') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GuideSection title="同步请求错误结构">
|
||||||
|
<pre>{`{\n "error": {\n "message": "api key scope does not allow this capability",\n "status": 403,\n "code": "forbidden"\n }\n}`}</pre>
|
||||||
|
<p>先判断 HTTP 状态,再读取 <code>error.code</code> 和 <code>error.message</code>;响应中包含 <code>requestId</code> 时请一并保存,便于关联上游请求和运行日志。</p>
|
||||||
|
</GuideSection>
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>常见状态与处理方式</h2></header>
|
||||||
|
<div className="docsStatusGrid">
|
||||||
|
<code>400 invalid_parameter</code><p>请求结构或参数不受支持,检查字段类型、必填项及模型能力。</p>
|
||||||
|
<code>401 unauthorized</code><p>缺少、失效或无法识别的 API Key。</p>
|
||||||
|
<code>403 forbidden</code><p>API Key 权限范围不包含当前能力,或账号无权访问该模型。</p>
|
||||||
|
<code>404 not_found</code><p>任务、模型或其他目标资源不存在。</p>
|
||||||
|
<code>429 rate_limit</code><p>触发 RPM、TPM 或并发限制;按响应和运行策略退避重试。</p>
|
||||||
|
<code>5xx provider_failed</code><p>Gateway 或上游供应商暂时失败;结合 retryable、attempts 和 requestId 排查。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<GuideSection title="异步任务失败">
|
||||||
|
<p>异步提交成功不代表生成成功。任务进入 <code>failed</code> 后,读取任务详情中的 <code>errorCode</code>、<code>errorMessage</code>、<code>requestId</code> 和 <code>attempts</code>。</p>
|
||||||
|
</GuideSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GuideSection title="启用请求级测试模式">
|
||||||
|
<p>在请求 Body 中同时设置 <code>runMode: simulation</code> 和 <code>simulation: true</code>。在线运行示例默认使用该配置。</p>
|
||||||
|
<pre>{`{\n "model": "gpt-4o-mini",\n "messages": [{ "role": "user", "content": "你好" }],\n "runMode": "simulation",\n "simulation": true\n}`}</pre>
|
||||||
|
</GuideSection>
|
||||||
|
<GuideSection title="测试模式的执行边界">
|
||||||
|
<p>测试任务仍会执行真实鉴权、模型路由、持久化队列、限流、进度事件和结果归一,因此可用于验证完整 Gateway 链路。</p>
|
||||||
|
<p>它不会向真实供应商提交任务、不会创建真实供应商订单,也不会产生真实扣费;任务、事件和结算结果会带有 <code>simulated=true</code> 标记。</p>
|
||||||
|
</GuideSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideSection(props: { children: ReactNode; title: string }) {
|
||||||
|
return (
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>{props.title}</h2></header>
|
||||||
|
<div className="docsDetailContent">{props.children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskResponseSchema() {
|
||||||
|
return (
|
||||||
|
<section className="paramCard">
|
||||||
|
<header>
|
||||||
|
<h2>响应 Body</h2>
|
||||||
|
<Badge variant="outline">application/json</Badge>
|
||||||
|
</header>
|
||||||
|
<ParamRows rows={taskResponseParamRows()} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocDetails(props: { section: ApiDocSection }) {
|
||||||
|
if (props.section === 'responses') {
|
||||||
|
return (
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>连续对话与状态归属</h2></header>
|
||||||
|
<div className="docsDetailContent">
|
||||||
|
<p>首次请求不传 <code>previous_response_id</code>。后续请求如需由 Responses 链续接,传入上一轮返回的 <code>response.id</code>。</p>
|
||||||
|
<p>未传 <code>previous_response_id</code> 时,网关以本轮完整 input 为准,不自动追加本地历史;客户端自行管理的完整上下文始终优先。</p>
|
||||||
|
<pre>{`{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"input": "继续上一轮回答",
|
||||||
|
"previous_response_id": "resp_..."
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.section === 'videoGeneration') {
|
||||||
|
return (
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>参考素材角色</h2></header>
|
||||||
|
<div className="docsDetailContent">
|
||||||
|
<p><code>image_url</code> 可使用 <code>first_frame</code>、<code>last_frame</code>、<code>reference_image</code>;<code>video_url</code> 和 <code>audio_url</code> 可作为视频、音频参考。实际数量和组合由所选模型能力约束。</p>
|
||||||
|
<p>视频生成通常耗时较长,生产调用建议设置 <code>X-Async: true</code>,再使用响应中的 <code>next.detail</code> 或任务取回接口查询结果。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.section === 'asyncMode') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="paramCard">
|
||||||
|
<header><h2>开启方式</h2></header>
|
||||||
|
<ParamRow name="X-Async" type="boolean" required value="在原请求 Header 中设置为 true;请求 URL、鉴权方式和 Body 均保持原接口定义不变。" />
|
||||||
|
</section>
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>同步与异步的区别</h2></header>
|
||||||
|
<div className="docsStatusGrid">
|
||||||
|
<code>请求 Body</code><p>始终使用原接口的参数。异步模式没有独立的模型、提示词或媒体参数。</p>
|
||||||
|
<code>未设置 X-Async</code><p>连接等待任务执行,并按原接口协议返回同步 JSON 或 SSE 流。</p>
|
||||||
|
<code>X-Async: true</code><p>任务进入持久化队列,接口立即返回 HTTP 202;客户端通过 taskId 取回状态和最终结果。</p>
|
||||||
|
<code>适用范围</code><p>作为 AI 任务创建接口的统一能力使用,不与某一种文本、图片、视频或音频 Body 绑定。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>异步受理响应</h2></header>
|
||||||
|
<div className="docsDetailContent">
|
||||||
|
<p>保存 <code>taskId</code>。<code>next.detail</code> 用于取回完整任务结果,<code>next.events</code> 用于订阅进度事件。</p>
|
||||||
|
<pre>{`HTTP/1.1 202 Accepted
|
||||||
|
{
|
||||||
|
"taskId": "9f4d8f3d-...",
|
||||||
|
"task": {
|
||||||
|
"id": "9f4d8f3d-...",
|
||||||
|
"status": "queued",
|
||||||
|
"asyncMode": true
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"detail": "/api/v1/tasks/9f4d8f3d-...",
|
||||||
|
"events": "/api/v1/tasks/9f4d8f3d-.../events"
|
||||||
|
}
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.section === 'taskRetrieve') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>状态与结果读取</h2></header>
|
||||||
|
<div className="docsStatusGrid">
|
||||||
|
<code>queued / submitting / running</code><p>任务仍在进行,保留 taskId 并继续查询;此时 <code>result</code> 通常为空。</p>
|
||||||
|
<code>succeeded</code><p>任务成功,原能力的归一化响应完整保存在 <code>result</code>;用量与结算分别位于 <code>usage</code>、<code>billings</code>。</p>
|
||||||
|
<code>failed</code><p>任务失败,读取 <code>errorCode</code>、<code>errorMessage</code>、<code>requestId</code> 和 <code>attempts</code>。</p>
|
||||||
|
<code>cancelled</code><p>任务已取消,不再继续执行;结合 <code>submitted</code> 判断是否曾提交到上游。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="paramCard docsDetailCard">
|
||||||
|
<header><h2>成功响应示例</h2></header>
|
||||||
|
<div className="docsDetailContent">
|
||||||
|
<p><code>result</code> 的内部字段与原始能力一致:文本、向量、重排序和媒体任务会保留各自的结果结构,外层任务字段保持统一。</p>
|
||||||
|
<pre>{`{
|
||||||
|
"id": "9f4d8f3d-...",
|
||||||
|
"kind": "images.generations",
|
||||||
|
"model": "image-model",
|
||||||
|
"asyncMode": true,
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": {
|
||||||
|
"data": [{ "url": "https://example.com/result.png" }]
|
||||||
|
},
|
||||||
|
"usage": { "totalTokens": 0 },
|
||||||
|
"billings": [{
|
||||||
|
"resourceType": "image",
|
||||||
|
"unit": "image",
|
||||||
|
"quantity": 1,
|
||||||
|
"amount": 0.1,
|
||||||
|
"currency": "resource",
|
||||||
|
"simulated": false
|
||||||
|
}],
|
||||||
|
"billingSummary": {
|
||||||
|
"lineCount": 1,
|
||||||
|
"totalAmount": 0.1,
|
||||||
|
"currency": "resource",
|
||||||
|
"simulated": false
|
||||||
|
},
|
||||||
|
"finalChargeAmount": 0.1,
|
||||||
|
"attempts": [{
|
||||||
|
"attemptNo": 1,
|
||||||
|
"provider": "provider-name",
|
||||||
|
"status": "succeeded",
|
||||||
|
"retryable": false,
|
||||||
|
"responseDurationMs": 1280
|
||||||
|
}],
|
||||||
|
"createdAt": "2026-07-17T07:00:00Z",
|
||||||
|
"updatedAt": "2026-07-17T07:00:02Z",
|
||||||
|
"finishedAt": "2026-07-17T07:00:02Z"
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizationDescription(section: ApiDocSection) {
|
||||||
|
const scope = section === 'embeddings'
|
||||||
|
? 'embedding'
|
||||||
|
: section === 'reranks'
|
||||||
|
? 'rerank'
|
||||||
|
: section === 'videoGeneration' || section === 'asyncMode'
|
||||||
|
? 'video'
|
||||||
|
: section === 'chat' || section === 'responses'
|
||||||
|
? 'chat'
|
||||||
|
: '';
|
||||||
|
return `Bearer {{YOUR_API_KEY}},支持本地 API Key;管理接口仍使用 JWT。${scope ? `当前接口需要 ${scope} 权限。` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsAsyncMode(section: ApiDocSection) {
|
||||||
|
return section === 'chat'
|
||||||
|
|| section === 'responses'
|
||||||
|
|| section === 'embeddings'
|
||||||
|
|| section === 'reranks'
|
||||||
|
|| section === 'imageGeneration'
|
||||||
|
|| section === 'imageEdit'
|
||||||
|
|| section === 'videoGeneration';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MethodBadge(props: { method: NonNullable<ApiDocItem['method']> }) {
|
||||||
|
return <Badge variant={props.method === 'GET' ? 'success' : 'warning'}>{props.method}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDocs(items: typeof apiDocs) {
|
||||||
|
const groups = new Map<string, typeof apiDocs>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
|
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
|
||||||
}
|
}
|
||||||
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
|
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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-')) {
|
||||||
|
return { ...next, taskId: taskResult.id };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
|
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
|
||||||
if (kind === 'chat.completions') return { ...current, kind, model: 'gpt-4o-mini' };
|
if (kind === 'chat.completions') return { ...current, kind, model: 'gpt-4o-mini' };
|
||||||
|
if (kind === 'responses') {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
instructions: current.instructions ?? '回答保持简洁。',
|
||||||
|
kind,
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
prompt: current.kind === kind ? current.prompt : '用一句话说明 Responses API 的用途。',
|
||||||
|
};
|
||||||
|
}
|
||||||
if (kind === 'embeddings') {
|
if (kind === 'embeddings') {
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
@ -360,18 +751,55 @@ function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === 'images.edits') return { ...current, kind, image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png', model: 'gpt-image-1' };
|
if (kind === 'images.edits') return { ...current, kind, image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png', model: 'gpt-image-1' };
|
||||||
return { ...current, kind, model: 'gpt-image-1' };
|
if (kind === 'images.generations') return { ...current, kind, model: 'gpt-image-1' };
|
||||||
|
if (kind === 'videos.generations') {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
aspectRatio: current.aspectRatio ?? '16:9',
|
||||||
|
duration: current.duration ?? 5,
|
||||||
|
kind,
|
||||||
|
model: '豆包Seedance-2.0',
|
||||||
|
outputAudio: current.outputAudio ?? true,
|
||||||
|
prompt: current.kind === kind ? current.prompt : '电影感航拍镜头掠过日出时的雪山。',
|
||||||
|
resolution: current.resolution ?? '720p',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...current, kind, model: 'task' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestBodyExample(task: TaskForm) {
|
function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||||
const body = task.kind === 'chat.completions'
|
const body = task.kind === 'chat.completions'
|
||||||
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, stream: false }
|
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, stream: false }
|
||||||
|
: task.kind === 'responses'
|
||||||
|
? {
|
||||||
|
model: task.model,
|
||||||
|
input: task.prompt,
|
||||||
|
instructions: task.instructions,
|
||||||
|
previous_response_id: task.previousResponseId || undefined,
|
||||||
|
store: true,
|
||||||
|
stream: false,
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
}
|
||||||
: task.kind === 'embeddings'
|
: task.kind === 'embeddings'
|
||||||
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4, runMode: 'simulation', simulation: true }
|
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4, runMode: 'simulation', simulation: true }
|
||||||
: task.kind === 'reranks'
|
: task.kind === 'reranks'
|
||||||
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2, runMode: 'simulation', simulation: true }
|
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2, runMode: 'simulation', simulation: true }
|
||||||
: task.kind === 'images.edits'
|
: task.kind === 'images.edits'
|
||||||
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, runMode: 'simulation', simulation: true }
|
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, runMode: 'simulation', simulation: true }
|
||||||
|
: task.kind === 'videos.generations'
|
||||||
|
? {
|
||||||
|
model: task.model,
|
||||||
|
content: [{ type: 'text', text: task.prompt }],
|
||||||
|
duration: task.duration ?? 5,
|
||||||
|
resolution: task.resolution ?? '720p',
|
||||||
|
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||||
|
audio: task.outputAudio ?? true,
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
}
|
||||||
|
: section === 'pricing'
|
||||||
|
? { kind: 'chat.completions', model: 'gpt-4o-mini', messages: [{ role: 'user', content: '你好' }], max_tokens: 512 }
|
||||||
: { model: task.model, prompt: task.prompt, quality: 'medium', runMode: 'simulation', simulation: true, size: '1024x1024' };
|
: { model: task.model, prompt: task.prompt, quality: 'medium', runMode: 'simulation', simulation: true, size: '1024x1024' };
|
||||||
return JSON.stringify(body, null, 2);
|
return JSON.stringify(body, null, 2);
|
||||||
}
|
}
|
||||||
@ -380,24 +808,37 @@ function parseBody(value: string, current: TaskForm): TaskForm {
|
|||||||
try {
|
try {
|
||||||
const body = JSON.parse(value) as {
|
const body = JSON.parse(value) as {
|
||||||
image?: string;
|
image?: string;
|
||||||
|
aspect_ratio?: string;
|
||||||
|
audio?: boolean;
|
||||||
|
content?: Array<{ text?: string; type?: string }>;
|
||||||
|
duration?: number;
|
||||||
|
instructions?: string;
|
||||||
mask?: string;
|
mask?: string;
|
||||||
messages?: Array<{ content?: string }>;
|
messages?: Array<{ content?: string }>;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
previous_response_id?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
input?: string | string[];
|
input?: unknown;
|
||||||
query?: string;
|
query?: string;
|
||||||
documents?: string[];
|
documents?: string[];
|
||||||
|
resolution?: string;
|
||||||
top_n?: number;
|
top_n?: number;
|
||||||
dimensions?: number;
|
dimensions?: number;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
|
aspectRatio: body.aspect_ratio ?? current.aspectRatio,
|
||||||
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
|
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
|
||||||
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
|
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
|
||||||
|
duration: numberOrCurrent(body.duration, current.duration),
|
||||||
image: body.image ?? current.image,
|
image: body.image ?? current.image,
|
||||||
|
instructions: body.instructions ?? current.instructions,
|
||||||
mask: body.mask ?? current.mask,
|
mask: body.mask ?? current.mask,
|
||||||
model: body.model ?? current.model,
|
model: body.model ?? current.model,
|
||||||
prompt: body.prompt ?? body.query ?? inputText(body.input) ?? body.messages?.[0]?.content ?? current.prompt,
|
outputAudio: typeof body.audio === 'boolean' ? body.audio : current.outputAudio,
|
||||||
|
previousResponseId: body.previous_response_id ?? current.previousResponseId,
|
||||||
|
prompt: body.prompt ?? body.query ?? inputText(body.input) ?? contentText(body.content) ?? body.messages?.[0]?.content ?? current.prompt,
|
||||||
|
resolution: body.resolution ?? current.resolution,
|
||||||
topN: numberOrCurrent(body.top_n, current.topN),
|
topN: numberOrCurrent(body.top_n, current.topN),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@ -405,7 +846,96 @@ function parseBody(value: string, current: TaskForm): TaskForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bodyParamRows(section: ApiDocSection) {
|
function taskResponseParamRows(): ApiDocParam[] {
|
||||||
|
return [
|
||||||
|
{ name: 'id', type: 'string', required: true, value: 'Gateway 任务 ID,与异步受理响应中的 taskId 相同' },
|
||||||
|
{ name: 'kind', type: 'string', required: true, value: '原始任务类型,例如 chat.completions、images.generations 或 videos.generations' },
|
||||||
|
{ name: 'model', type: 'string', required: true, value: '请求使用的模型 ID 或别名' },
|
||||||
|
{ name: 'status', type: 'string', required: true, value: 'queued、submitting、running、succeeded、failed 或 cancelled' },
|
||||||
|
{ name: 'asyncMode', type: 'boolean', required: true, value: '是否以异步模式提交' },
|
||||||
|
{ name: 'cancellable', type: 'boolean', value: '当前状态是否允许取消' },
|
||||||
|
{ name: 'submitted', type: 'boolean', value: '任务是否已经提交到上游供应商' },
|
||||||
|
{ name: 'message', type: 'string', value: '当前状态或取消能力的补充说明' },
|
||||||
|
{ name: 'requestId', type: 'string', value: '上游请求 ID,用于日志和供应商问题定位' },
|
||||||
|
{ name: 'result', type: 'object', value: '任务成功后的归一化结果;内部结构与原始文本、向量、重排序、图片、视频或音频接口一致' },
|
||||||
|
{
|
||||||
|
name: 'usage',
|
||||||
|
type: 'object',
|
||||||
|
value: '归一化用量数据;媒体任务可能使用能力相关的用量字段',
|
||||||
|
children: [
|
||||||
|
{ name: 'inputTokens', type: 'number', value: '输入 token 数' },
|
||||||
|
{ name: 'outputTokens', type: 'number', value: '输出 token 数' },
|
||||||
|
{ name: 'totalTokens', type: 'number', value: '总 token 数' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'billings',
|
||||||
|
type: 'array<object>',
|
||||||
|
value: '逐项计费明细',
|
||||||
|
children: [
|
||||||
|
{ name: 'resourceType', type: 'string', required: true, value: '计费资源类型,例如 text_input、image 或 video' },
|
||||||
|
{ name: 'unit', type: 'string', required: true, value: '计费单位,例如 1k_tokens、image 或 5s_video' },
|
||||||
|
{ name: 'quantity', type: 'number', required: true, value: '计费数量' },
|
||||||
|
{ name: 'amount', type: 'number', required: true, value: '本计费项金额' },
|
||||||
|
{ name: 'currency', type: 'string', required: true, value: '金额单位,默认 resource' },
|
||||||
|
{ name: 'discountFactor', type: 'number', value: '实际应用的折扣系数' },
|
||||||
|
{ name: 'simulated', type: 'boolean', required: true, value: '是否为模拟计费' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'billingSummary',
|
||||||
|
type: 'object',
|
||||||
|
value: '计费汇总',
|
||||||
|
children: [
|
||||||
|
{ name: 'lineCount', type: 'number', required: true, value: '计费明细条数' },
|
||||||
|
{ name: 'totalAmount', type: 'number', required: true, value: '汇总金额' },
|
||||||
|
{ name: 'amountByCurrency', type: 'object', required: true, value: '按金额单位汇总的金额映射' },
|
||||||
|
{ name: 'currency', type: 'string', required: true, value: '单一金额单位或 mixed' },
|
||||||
|
{ name: 'simulated', type: 'boolean', required: true, value: '是否为模拟计费汇总' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'finalChargeAmount', type: 'number', required: true, value: '最终实际扣费金额;模拟任务不会真实扣费' },
|
||||||
|
{
|
||||||
|
name: 'attempts',
|
||||||
|
type: 'array<object>',
|
||||||
|
value: '执行尝试记录;发生重试或切换平台时可能包含多项',
|
||||||
|
children: [
|
||||||
|
{ name: 'attemptNo', type: 'number', required: true, value: '尝试序号,从 1 开始' },
|
||||||
|
{ name: 'provider', type: 'string', value: '供应商标识' },
|
||||||
|
{ name: 'modelName', type: 'string', value: 'Gateway 模型名称' },
|
||||||
|
{ name: 'providerModelName', type: 'string', value: '实际上游模型名称' },
|
||||||
|
{ name: 'status', type: 'string', required: true, value: '本次尝试状态' },
|
||||||
|
{ name: 'retryable', type: 'boolean', required: true, value: '失败后是否允许重试' },
|
||||||
|
{ name: 'simulated', type: 'boolean', required: true, value: '是否由 SimulationClient 执行' },
|
||||||
|
{ name: 'requestId', type: 'string', value: '本次尝试的上游请求 ID' },
|
||||||
|
{ name: 'responseDurationMs', type: 'number', required: true, value: '本次尝试的响应耗时(毫秒)' },
|
||||||
|
{ name: 'errorCode', type: 'string', value: '本次尝试失败时的错误码' },
|
||||||
|
{ name: 'errorMessage', type: 'string', value: '本次尝试失败时的错误信息' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'errorCode', type: 'string', value: '任务最终失败时的错误码' },
|
||||||
|
{ name: 'errorMessage', type: 'string', value: '任务最终失败时的错误信息' },
|
||||||
|
{ name: 'responseDurationMs', type: 'number', required: true, value: '任务响应耗时(毫秒)' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, value: '任务创建时间,ISO 8601 格式' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, value: '任务最后更新时间,ISO 8601 格式' },
|
||||||
|
{ name: 'finishedAt', type: 'string', value: '任务进入终态的时间,ISO 8601 格式' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyParamRows(section: ApiDocSection): ApiDocParam[] {
|
||||||
|
if (section === 'responses') {
|
||||||
|
return [
|
||||||
|
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名;网关优先选择支持 openai_responses 的平台模型' },
|
||||||
|
{ name: 'input', type: 'string|array', required: true, value: '本轮输入,可为文本或 Responses input item 数组' },
|
||||||
|
{ name: 'instructions', type: 'string', value: '本轮系统级指令' },
|
||||||
|
{ name: 'previous_response_id', type: 'string', value: '上一轮 response.id;用于由客户端显式管理连续对话' },
|
||||||
|
{ name: 'store', type: 'boolean', value: '是否保存 Responses 链,默认 true' },
|
||||||
|
{ name: 'stream', type: 'boolean', value: '设为 true 时返回 Responses SSE 事件流' },
|
||||||
|
{ name: 'tools / tool_choice', type: 'array|object', value: '函数工具定义与选择策略;Chat 回退只支持 function tools' },
|
||||||
|
{ name: 'reasoning / text', type: 'object', value: '推理强度、文本格式和结构化输出配置' },
|
||||||
|
{ name: 'background', type: 'boolean', value: 'OpenAI 协议字段;Gateway 的持久化异步任务请使用 X-Async' },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (section === 'embeddings') {
|
if (section === 'embeddings') {
|
||||||
return [
|
return [
|
||||||
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名,例如 text-embedding-v4' },
|
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名,例如 text-embedding-v4' },
|
||||||
@ -423,19 +953,131 @@ function bodyParamRows(section: ApiDocSection) {
|
|||||||
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不消耗真实上游额度' },
|
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不消耗真实上游额度' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (section === 'videoGeneration') {
|
||||||
|
return [
|
||||||
|
{ name: 'model', type: 'string', required: true, value: '视频模型 ID 或别名,例如 豆包Seedance-2.0' },
|
||||||
|
{
|
||||||
|
name: 'content',
|
||||||
|
type: 'array<object>',
|
||||||
|
required: true,
|
||||||
|
value: '文本和参考素材对象数组;每一项由 type 决定应填写的内容字段和 role',
|
||||||
|
children: [
|
||||||
|
{ name: 'type', type: 'string', required: true, value: '内容类型:text、image_url、video_url、audio_url 或 element' },
|
||||||
|
{ name: 'text', type: 'string', value: 'type=text 时的提示词;配合 role=shot_prompt 可描述指定分镜' },
|
||||||
|
{
|
||||||
|
name: 'image_url',
|
||||||
|
type: 'object',
|
||||||
|
value: 'type=image_url 时的图片素材',
|
||||||
|
children: [
|
||||||
|
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的图片 URL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'video_url',
|
||||||
|
type: 'object',
|
||||||
|
value: 'type=video_url 时的视频素材及参考方式',
|
||||||
|
children: [
|
||||||
|
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的视频 URL' },
|
||||||
|
{ name: 'mime_type', type: 'string', value: '视频 MIME 类型,例如 video/mp4' },
|
||||||
|
{ name: 'refer_type', type: 'string', value: '参考方式:feature(特征参考)或 base(基础视频)' },
|
||||||
|
{ name: 'keep_original_sound', type: 'string', value: '是否保留原声:yes 或 no' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'audio_url',
|
||||||
|
type: 'object',
|
||||||
|
value: 'type=audio_url 时的音频素材',
|
||||||
|
children: [
|
||||||
|
{ name: 'url', type: 'string', required: true, value: '可公开访问或已上传的音频 URL' },
|
||||||
|
{ name: 'mime_type', type: 'string', value: '音频 MIME 类型,例如 audio/mpeg' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'role', type: 'string', value: '素材角色,例如 first_frame、last_frame、reference_image、reference_video、reference_audio、element 或 shot_prompt' },
|
||||||
|
{ name: 'shot_index', type: 'number', value: '素材或提示词对应的分镜序号' },
|
||||||
|
{ name: 'duration', type: 'number', value: '当前分镜或素材的时长(秒)' },
|
||||||
|
{ name: 'name', type: 'string', value: '素材或元素名称' },
|
||||||
|
{
|
||||||
|
name: 'element',
|
||||||
|
type: 'object',
|
||||||
|
value: 'type=element 时使用的系统元素或内联元素',
|
||||||
|
children: [
|
||||||
|
{ name: 'system_element_id', type: 'string', value: '已存在的系统元素 ID' },
|
||||||
|
{
|
||||||
|
name: 'inline_element',
|
||||||
|
type: 'object',
|
||||||
|
value: '随请求提交的内联元素定义',
|
||||||
|
children: [
|
||||||
|
{ name: 'name', type: 'string', required: true, value: '内联元素名称' },
|
||||||
|
{ name: 'description', type: 'string', value: '内联元素描述' },
|
||||||
|
{ name: 'frontal_image_url', type: 'string', required: true, value: '元素正面参考图 URL' },
|
||||||
|
{
|
||||||
|
name: 'refer_images',
|
||||||
|
type: 'array<object>',
|
||||||
|
required: true,
|
||||||
|
value: '元素的补充参考图片对象数组',
|
||||||
|
children: [
|
||||||
|
{ name: 'url', type: 'string', required: true, value: '参考图片 URL' },
|
||||||
|
{ name: 'slot_key', type: 'string', value: '参考图槽位标识' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'tags', type: 'array<string>', value: '元素标签数组' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'duration', type: 'number', value: '视频时长(秒),允许范围取决于模型能力' },
|
||||||
|
{ name: 'resolution', type: 'string', value: '输出分辨率,例如 480p、720p、1080p' },
|
||||||
|
{ name: 'aspect_ratio', type: 'string', value: '画幅,例如 16:9、9:16、1:1 或 adaptive' },
|
||||||
|
{ name: 'audio', type: 'boolean', value: '是否生成或保留声音,需模型支持 output_audio' },
|
||||||
|
{
|
||||||
|
name: 'audio_list',
|
||||||
|
type: 'array<object>',
|
||||||
|
value: '额外音频素材对象数组',
|
||||||
|
children: [
|
||||||
|
{ name: 'url', type: 'string', value: '音频 URL' },
|
||||||
|
{ name: 'audio_url', type: 'string', value: '音频 URL 的兼容字段' },
|
||||||
|
{ name: 'name', type: 'string', value: '音频名称' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'prompt', type: 'string', value: '纯文生视频的快捷字段;网关会转换为 content 文本项' },
|
||||||
|
{ name: 'runMode / simulation', type: 'string|boolean', value: '在线测试时使用 simulation,不触达真实视频供应商' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (section === 'pricing') {
|
||||||
|
return [
|
||||||
|
{ name: 'kind', type: 'string', value: '任务类型,默认 chat.completions' },
|
||||||
|
{ name: 'model', type: 'string', required: true, value: '需要估价的模型 ID 或别名' },
|
||||||
|
{ name: 'messages / prompt', type: 'array|string', value: '用于估算输入规模的请求内容' },
|
||||||
|
{ name: 'max_tokens / n', type: 'number', value: '预期输出 token 或媒体数量' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (section === 'chat') {
|
||||||
return [
|
return [
|
||||||
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名' },
|
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名' },
|
||||||
{ name: 'messages / prompt', type: 'array|string', required: true, value: '对话消息或图片提示词' },
|
{ name: 'messages', type: 'array', required: true, value: 'OpenAI Chat 消息数组' },
|
||||||
|
{ name: 'stream', type: 'boolean', value: '设为 true 时返回 Chat Completions SSE' },
|
||||||
|
{ name: 'tools / tool_choice', type: 'array|object', value: '函数工具定义与选择策略' },
|
||||||
|
{ name: 'simulation', type: 'boolean', value: '测试模式开关' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ name: 'model', type: 'string', required: true, value: '模型 ID 或别名' },
|
||||||
|
{ name: 'prompt', type: 'string', required: true, value: '图片生成或编辑提示词' },
|
||||||
|
{ name: 'image / mask', type: 'string|array', value: '图像编辑输入与可选遮罩' },
|
||||||
{ name: 'simulation', type: 'boolean', value: '测试模式开关' },
|
{ name: 'simulation', type: 'boolean', value: '测试模式开关' },
|
||||||
{ name: 'stream', type: 'boolean', value: '对话进度流式返回' },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function docSectionForKind(kind: TaskKind): ApiDocSection {
|
function docSectionForKind(kind: TaskKind): ApiDocSection {
|
||||||
|
if (kind === 'responses') return 'responses';
|
||||||
if (kind === 'embeddings') return 'embeddings';
|
if (kind === 'embeddings') return 'embeddings';
|
||||||
if (kind === 'reranks') return 'reranks';
|
if (kind === 'reranks') return 'reranks';
|
||||||
if (kind === 'images.generations') return 'imageGeneration';
|
if (kind === 'images.generations') return 'imageGeneration';
|
||||||
if (kind === 'images.edits') return 'imageEdit';
|
if (kind === 'images.edits') return 'imageEdit';
|
||||||
|
if (kind === 'videos.generations') return 'videoGeneration';
|
||||||
|
if (kind === 'tasks.retrieve') return 'taskRetrieve';
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,9 +1099,17 @@ function splitLines(value: string) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputText(value: string | string[] | undefined) {
|
function inputText(value: unknown) {
|
||||||
if (Array.isArray(value)) return value.join('\n');
|
if (typeof value === 'string') return value;
|
||||||
return value;
|
if (Array.isArray(value)) {
|
||||||
|
const texts = value.map((item) => typeof item === 'string' ? item : '').filter(Boolean);
|
||||||
|
return texts.length ? texts.join('\n') : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentText(value?: Array<{ text?: string; type?: string }>) {
|
||||||
|
return value?.find((item) => item.type === 'text' && item.text)?.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberOrCurrent(value: unknown, current?: number) {
|
function numberOrCurrent(value: unknown, current?: number) {
|
||||||
|
|||||||
27
apps/web/src/routing.test.ts
Normal file
27
apps/web/src/routing.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user