feat(web): 支持文档页真实提交请求
This commit is contained in:
@@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => {
|
||||
expect(html).toContain('任务取回接口');
|
||||
});
|
||||
|
||||
it('defaults the online runner to test mode and offers an explicit real submission mode', () => {
|
||||
const html = renderDocs('imageEdit', { kind: 'images.edits', model: 'gpt-image-1', prompt: '移除背景' });
|
||||
|
||||
expect(html).toContain('运行模式');
|
||||
expect(html).toContain('测试模式');
|
||||
expect(html).toContain('真实提交');
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
expect(html).toContain('"runMode": "simulation"');
|
||||
expect(html).toContain('"simulation": true');
|
||||
});
|
||||
|
||||
it('documents async mode as a body-independent capability', () => {
|
||||
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Fragment, useEffect, 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, Input, Select, Textarea } from '../components/ui';
|
||||
import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
|
||||
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
|
||||
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
||||
|
||||
interface ApiDocItem {
|
||||
@@ -90,7 +91,7 @@ export function ApiDocsPage(props: {
|
||||
onCreateApiKey: () => void;
|
||||
onLogin: () => void;
|
||||
onDocSectionChange: (value: ApiDocSection) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>, options?: RunTaskOptions) => void;
|
||||
onTaskFormChange: (value: TaskForm) => void;
|
||||
}) {
|
||||
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
|
||||
@@ -101,12 +102,12 @@ export function ApiDocsPage(props: {
|
||||
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
|
||||
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
|
||||
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
|
||||
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
|
||||
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||
const bodyExample = useMemo(
|
||||
() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'),
|
||||
[currentApiDoc?.key, props.taskForm],
|
||||
);
|
||||
const [submissionMode, setSubmissionMode] = useState<TaskSubmissionMode>('simulation');
|
||||
const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
const [bodyError, setBodyError] = useState('');
|
||||
const runnerPath = currentApiDoc?.path
|
||||
? isTaskRetrieveDoc
|
||||
? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}')
|
||||
@@ -119,6 +120,12 @@ export function ApiDocsPage(props: {
|
||||
}
|
||||
}, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSubmissionMode('simulation');
|
||||
setBodyDraft(requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
setBodyError('');
|
||||
}, [currentApiDoc?.key, props.taskForm]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getOpsManagementSkillMetadata()
|
||||
@@ -134,18 +141,55 @@ export function ApiDocsPage(props: {
|
||||
}, []);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!runnerAvailable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!props.canRun) {
|
||||
event.preventDefault();
|
||||
props.onLogin();
|
||||
return;
|
||||
}
|
||||
if (runnerModeEnabled) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
const requestBody = applyTaskSubmissionMode(parsed.body, submissionMode);
|
||||
setBodyDraft(JSON.stringify(requestBody, null, 2));
|
||||
setBodyError('');
|
||||
props.onSubmitTask(event, { requestBody, submissionMode });
|
||||
return;
|
||||
}
|
||||
props.onSubmitTask(event);
|
||||
}
|
||||
|
||||
function handleBodyChange(value: string) {
|
||||
setBodyDraft(value);
|
||||
setBodyError(parseEditableRequestBody(value).error);
|
||||
}
|
||||
|
||||
function handleBodyBlur() {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, submissionMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleSubmissionModeChange(nextMode: TaskSubmissionMode) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError('请先修正请求 Body 的 JSON 格式,再切换运行模式。');
|
||||
return;
|
||||
}
|
||||
setSubmissionMode(nextMode);
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, nextMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleDocClick(item: ApiDocItem) {
|
||||
if (item.kind) {
|
||||
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
|
||||
@@ -257,7 +301,7 @@ export function ApiDocsPage(props: {
|
||||
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
|
||||
<header>
|
||||
<strong>在线运行</strong>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || (props.canRun && props.coreState === 'loading')}>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Send size={14} />
|
||||
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
||||
</Button>
|
||||
@@ -286,6 +330,34 @@ export function ApiDocsPage(props: {
|
||||
)}
|
||||
{runnerAvailable ? (
|
||||
<>
|
||||
{runnerModeEnabled && (
|
||||
<div className="runnerModeField">
|
||||
<span className="runnerModeLabel">运行模式</span>
|
||||
<div className="runnerModeToggle" role="group" aria-label="运行模式">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'simulation'}
|
||||
data-active={submissionMode === 'simulation'}
|
||||
onClick={() => handleSubmissionModeChange('simulation')}
|
||||
>
|
||||
测试模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'production'}
|
||||
data-active={submissionMode === 'production'}
|
||||
onClick={() => handleSubmissionModeChange('production')}
|
||||
>
|
||||
真实提交
|
||||
</button>
|
||||
</div>
|
||||
<p className="runnerModeHint" data-mode={submissionMode}>
|
||||
{submissionMode === 'simulation'
|
||||
? '不触达真实供应商,不消耗上游额度。'
|
||||
: '请求将真实提交给供应商,可能产生费用。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="shLabel">
|
||||
能力
|
||||
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
|
||||
@@ -304,14 +376,29 @@ export function ApiDocsPage(props: {
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
||||
</label>
|
||||
<div className="runnerBodyField">
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea
|
||||
aria-describedby={bodyError ? 'docs-runner-body-error' : undefined}
|
||||
aria-invalid={Boolean(bodyError)}
|
||||
value={bodyDraft}
|
||||
onBlur={handleBodyBlur}
|
||||
onChange={(event) => handleBodyChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{bodyError && <span className="runnerBodyError" id="docs-runner-body-error" role="alert">{bodyError}</span>}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
|
||||
<Button type="submit" disabled={Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Play size={15} />
|
||||
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
||||
{!props.canRun
|
||||
? '登录后运行'
|
||||
: props.coreState === 'loading'
|
||||
? '运行中'
|
||||
: submissionMode === 'simulation' || !runnerModeEnabled
|
||||
? '运行测试'
|
||||
: '真实提交'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -767,9 +854,9 @@ function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm
|
||||
return { ...current, kind, model: 'task' };
|
||||
}
|
||||
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection, submissionMode: TaskSubmissionMode) {
|
||||
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 }], stream: false }
|
||||
: task.kind === 'responses'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -778,15 +865,13 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
previous_response_id: task.previousResponseId || undefined,
|
||||
store: true,
|
||||
stream: false,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
}
|
||||
: 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 }
|
||||
: 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 }
|
||||
: 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 }
|
||||
: task.kind === 'videos.generations'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -795,54 +880,23 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
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' };
|
||||
return JSON.stringify(body, null, 2);
|
||||
: { model: task.model, prompt: task.prompt, quality: 'medium', size: '1024x1024' };
|
||||
return JSON.stringify(section === 'pricing' ? body : applyTaskSubmissionMode(body, submissionMode), null, 2);
|
||||
}
|
||||
|
||||
function parseBody(value: string, current: TaskForm): TaskForm {
|
||||
function parseEditableRequestBody(value: string): { body: Record<string, unknown> | null; error: string } {
|
||||
try {
|
||||
const body = JSON.parse(value) as {
|
||||
image?: string;
|
||||
aspect_ratio?: string;
|
||||
audio?: boolean;
|
||||
content?: Array<{ text?: string; type?: string }>;
|
||||
duration?: number;
|
||||
instructions?: string;
|
||||
mask?: string;
|
||||
messages?: Array<{ content?: string }>;
|
||||
model?: string;
|
||||
previous_response_id?: string;
|
||||
prompt?: string;
|
||||
input?: unknown;
|
||||
query?: string;
|
||||
documents?: string[];
|
||||
resolution?: string;
|
||||
top_n?: number;
|
||||
dimensions?: number;
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
aspectRatio: body.aspect_ratio ?? current.aspectRatio,
|
||||
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
|
||||
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
|
||||
duration: numberOrCurrent(body.duration, current.duration),
|
||||
image: body.image ?? current.image,
|
||||
instructions: body.instructions ?? current.instructions,
|
||||
mask: body.mask ?? current.mask,
|
||||
model: body.model ?? current.model,
|
||||
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),
|
||||
};
|
||||
} catch {
|
||||
return current;
|
||||
const body = JSON.parse(value) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { body: null, error: '请求 Body 必须是 JSON 对象。' };
|
||||
}
|
||||
return { body: body as Record<string, unknown>, error: '' };
|
||||
} catch (error) {
|
||||
const detail = error instanceof SyntaxError ? error.message : '无法解析 JSON';
|
||||
return { body: null, error: `JSON 格式有误:${detail}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,20 +1152,3 @@ function splitLines(value: string) {
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inputText(value: unknown) {
|
||||
if (typeof value === 'string') 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) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : current;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user