feat(web): 支持文档页真实提交请求
This commit is contained in:
@@ -133,7 +133,7 @@ import {
|
|||||||
startOIDCLogin,
|
startOIDCLogin,
|
||||||
startOIDCLogout,
|
startOIDCLogout,
|
||||||
} from './lib/oidc';
|
} from './lib/oidc';
|
||||||
import { runTask } from './lib/run-task';
|
import { runTask, type RunTaskOptions } from './lib/run-task';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
@@ -1149,7 +1149,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTask(event: FormEvent<HTMLFormElement>) {
|
async function submitTask(event: FormEvent<HTMLFormElement>, options: RunTaskOptions = {}) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
|
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
|
||||||
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
|
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
|
||||||
@@ -1160,11 +1160,12 @@ export function App() {
|
|||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const response = await runTask(credential, taskForm);
|
const response = await runTask(credential, taskForm, options);
|
||||||
|
const completionMessage = response.submissionMode === 'simulation' ? '完成测试模式运行' : '完成真实提交';
|
||||||
if (response.localOnly) {
|
if (response.localOnly) {
|
||||||
setTaskResult(response.task);
|
setTaskResult(response.task);
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const syncTask = (detail: GatewayTask) => {
|
const syncTask = (detail: GatewayTask) => {
|
||||||
@@ -1176,7 +1177,7 @@ export function App() {
|
|||||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||||
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCoreState('error');
|
setCoreState('error');
|
||||||
setCoreMessage(err instanceof Error ? err.message : '测试任务失败');
|
setCoreMessage(err instanceof Error ? err.message : '测试任务失败');
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
retireIdentityPairingSecurityEventConflict,
|
retireIdentityPairingSecurityEventConflict,
|
||||||
validateIdentityRevision,
|
validateIdentityRevision,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import { applyTaskSubmissionMode, runTask } from './lib/run-task';
|
||||||
|
|
||||||
describe('local login transport', () => {
|
describe('local login transport', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -316,4 +317,46 @@ describe('API documentation runner transports', () => {
|
|||||||
expect(url).toContain('/api/v1/tasks/task-123');
|
expect(url).toContain('/api/v1/tasks/task-123');
|
||||||
expect(init.method).toBe('GET');
|
expect(init.method).toBe('GET');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes every simulation switch from a real submission while preserving edited parameters', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'chatcmpl-real' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await runTask(
|
||||||
|
'sk-test',
|
||||||
|
{ kind: 'chat.completions', model: 'gpt-fallback', prompt: 'fallback' },
|
||||||
|
{
|
||||||
|
submissionMode: 'production',
|
||||||
|
requestBody: {
|
||||||
|
model: 'gpt-real',
|
||||||
|
messages: [{ role: 'user', content: 'edited body' }],
|
||||||
|
temperature: 0.25,
|
||||||
|
runMode: 'simulation',
|
||||||
|
run_mode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
testMode: true,
|
||||||
|
test_mode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
model: 'gpt-real',
|
||||||
|
messages: [{ role: 'user', content: 'edited body' }],
|
||||||
|
temperature: 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses canonical simulation parameters without removing a model-specific mode field', () => {
|
||||||
|
expect(applyTaskSubmissionMode({ model: 'video-model', mode: 'pro', testMode: false }, 'simulation')).toEqual({
|
||||||
|
model: 'video-model',
|
||||||
|
mode: 'pro',
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,101 +9,97 @@ import {
|
|||||||
createVideoGenerationTask,
|
createVideoGenerationTask,
|
||||||
getAPITask,
|
getAPITask,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import type { TaskForm } from '../types';
|
import type { TaskForm, TaskSubmissionMode } from '../types';
|
||||||
|
|
||||||
export interface RunTaskResponse {
|
export interface RunTaskResponse {
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
next?: Record<string, string>;
|
next?: Record<string, string>;
|
||||||
|
submissionMode: TaskSubmissionMode;
|
||||||
task: GatewayTask;
|
task: GatewayTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTask(token: string, task: TaskForm): Promise<RunTaskResponse> {
|
export interface RunTaskOptions {
|
||||||
|
requestBody?: Record<string, unknown>;
|
||||||
|
submissionMode?: TaskSubmissionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const;
|
||||||
|
|
||||||
|
export function applyTaskSubmissionMode(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
submissionMode: TaskSubmissionMode,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const body = { ...input };
|
||||||
|
for (const key of simulationParameterKeys) delete body[key];
|
||||||
|
if (submissionMode === 'simulation') {
|
||||||
|
body.runMode = 'simulation';
|
||||||
|
body.simulation = true;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTask(token: string, task: TaskForm, options: RunTaskOptions = {}): Promise<RunTaskResponse> {
|
||||||
|
const submissionMode = options.submissionMode ?? 'simulation';
|
||||||
|
const requestBody = task.kind === 'tasks.retrieve'
|
||||||
|
? { taskId: task.taskId }
|
||||||
|
: applyTaskSubmissionMode(options.requestBody ?? defaultRequestBody(task), submissionMode);
|
||||||
|
|
||||||
if (task.kind === 'chat.completions') {
|
if (task.kind === 'chat.completions') {
|
||||||
const result = await createCompatibleChatCompletion(token, {
|
const result = await createCompatibleChatCompletion(
|
||||||
model: task.model,
|
token,
|
||||||
runMode: 'simulation',
|
requestBody as Parameters<typeof createCompatibleChatCompletion>[1],
|
||||||
simulation: true,
|
);
|
||||||
stream: false,
|
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||||
messages: [{ role: 'user', content: task.prompt }],
|
|
||||||
});
|
|
||||||
return { localOnly: true, task: compatibleTask(task, result) };
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'responses') {
|
if (task.kind === 'responses') {
|
||||||
const result = await createResponse(token, {
|
const result = await createResponse(token, requestBody as Parameters<typeof createResponse>[1]);
|
||||||
model: task.model,
|
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||||
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, requestBody as Parameters<typeof createEmbedding>[1]);
|
||||||
model: task.model,
|
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||||
input: embeddingInput(task.prompt),
|
|
||||||
dimensions: task.dimensions,
|
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
});
|
|
||||||
return { localOnly: true, task: compatibleTask(task, result) };
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'reranks') {
|
if (task.kind === 'reranks') {
|
||||||
const result = await createRerank(token, {
|
const result = await createRerank(token, requestBody as Parameters<typeof createRerank>[1]);
|
||||||
model: task.model,
|
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||||
query: task.prompt,
|
|
||||||
documents: rerankDocuments(task.documents),
|
|
||||||
top_n: task.topN,
|
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
});
|
|
||||||
return { localOnly: true, task: compatibleTask(task, result) };
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'images.generations') {
|
if (task.kind === 'images.generations') {
|
||||||
return createImageGenerationTask(token, {
|
const response = await createImageGenerationTask(
|
||||||
model: task.model,
|
token,
|
||||||
prompt: task.prompt,
|
requestBody as Parameters<typeof createImageGenerationTask>[1],
|
||||||
quality: 'medium',
|
);
|
||||||
runMode: 'simulation',
|
return { ...response, submissionMode };
|
||||||
simulation: true,
|
|
||||||
size: '1024x1024',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'images.edits') {
|
if (task.kind === 'images.edits') {
|
||||||
return createImageEditTask(token, {
|
const response = await createImageEditTask(token, requestBody as Parameters<typeof createImageEditTask>[1]);
|
||||||
model: task.model,
|
return { ...response, submissionMode };
|
||||||
prompt: task.prompt,
|
|
||||||
image: task.image,
|
|
||||||
mask: task.mask,
|
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'videos.generations') {
|
if (task.kind === 'videos.generations') {
|
||||||
return createVideoGenerationTask(token, {
|
const response = await createVideoGenerationTask(
|
||||||
model: task.model,
|
token,
|
||||||
content: [{ type: 'text', text: task.prompt }],
|
requestBody as unknown as Parameters<typeof createVideoGenerationTask>[1],
|
||||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
);
|
||||||
resolution: task.resolution ?? '720p',
|
return { ...response, submissionMode };
|
||||||
duration: task.duration ?? 5,
|
|
||||||
audio: task.outputAudio ?? true,
|
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (task.kind === 'tasks.retrieve') {
|
if (task.kind === 'tasks.retrieve') {
|
||||||
const taskId = task.taskId?.trim();
|
const taskId = task.taskId?.trim();
|
||||||
if (!taskId) throw new Error('请输入要取回的 Task ID');
|
if (!taskId) throw new Error('请输入要取回的 Task ID');
|
||||||
const result = await getAPITask(token, taskId);
|
const result = await getAPITask(token, taskId);
|
||||||
return { localOnly: true, task: compatibleTask(task, result as unknown as Record<string, unknown>) };
|
return {
|
||||||
|
localOnly: true,
|
||||||
|
submissionMode: 'production',
|
||||||
|
task: compatibleTask(task, result as unknown as Record<string, unknown>, requestBody, 'production'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
throw new Error(`Unsupported task kind: ${task.kind}`);
|
throw new Error(`Unsupported task kind: ${task.kind}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compatibleTask(task: TaskForm, result: Record<string, unknown>): GatewayTask {
|
function compatibleTask(
|
||||||
|
task: TaskForm,
|
||||||
|
result: Record<string, unknown>,
|
||||||
|
requestBody: Record<string, unknown>,
|
||||||
|
submissionMode: TaskSubmissionMode,
|
||||||
|
): GatewayTask {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id: `docs-${task.kind}-${Date.now()}`,
|
id: `docs-${task.kind}-${Date.now()}`,
|
||||||
@@ -111,26 +107,31 @@ function compatibleTask(task: TaskForm, result: Record<string, unknown>): Gatewa
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
finishedAt: now,
|
finishedAt: now,
|
||||||
kind: task.kind,
|
kind: task.kind,
|
||||||
model: task.model,
|
model: typeof requestBody.model === 'string' ? requestBody.model : task.model,
|
||||||
modelType: modelTypeForKind(task.kind),
|
modelType: modelTypeForKind(task.kind),
|
||||||
request: requestSnapshot(task),
|
request: requestBody,
|
||||||
result,
|
result,
|
||||||
runMode: 'simulation',
|
runMode: submissionMode,
|
||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
userId: 'docs-runner',
|
userId: 'docs-runner',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
function defaultRequestBody(task: TaskForm): Record<string, unknown> {
|
||||||
|
if (task.kind === 'chat.completions') {
|
||||||
|
return {
|
||||||
|
model: task.model,
|
||||||
|
messages: [{ role: 'user', content: task.prompt }],
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (task.kind === 'responses') {
|
if (task.kind === 'responses') {
|
||||||
return {
|
return {
|
||||||
model: task.model,
|
model: task.model,
|
||||||
input: task.prompt,
|
input: task.prompt,
|
||||||
instructions: task.instructions,
|
instructions: task.instructions,
|
||||||
previous_response_id: task.previousResponseId,
|
previous_response_id: task.previousResponseId,
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
store: true,
|
store: true,
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
@@ -140,8 +141,6 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
|||||||
model: task.model,
|
model: task.model,
|
||||||
input: embeddingInput(task.prompt),
|
input: embeddingInput(task.prompt),
|
||||||
dimensions: task.dimensions,
|
dimensions: task.dimensions,
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (task.kind === 'reranks') {
|
if (task.kind === 'reranks') {
|
||||||
@@ -150,8 +149,22 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
|||||||
query: task.prompt,
|
query: task.prompt,
|
||||||
documents: rerankDocuments(task.documents),
|
documents: rerankDocuments(task.documents),
|
||||||
top_n: task.topN,
|
top_n: task.topN,
|
||||||
runMode: 'simulation',
|
};
|
||||||
simulation: true,
|
}
|
||||||
|
if (task.kind === 'images.generations') {
|
||||||
|
return {
|
||||||
|
model: task.model,
|
||||||
|
prompt: task.prompt,
|
||||||
|
quality: 'medium',
|
||||||
|
size: '1024x1024',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (task.kind === 'images.edits') {
|
||||||
|
return {
|
||||||
|
model: task.model,
|
||||||
|
prompt: task.prompt,
|
||||||
|
image: task.image,
|
||||||
|
mask: task.mask,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (task.kind === 'videos.generations') {
|
if (task.kind === 'videos.generations') {
|
||||||
@@ -162,18 +175,10 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
|||||||
resolution: task.resolution ?? '720p',
|
resolution: task.resolution ?? '720p',
|
||||||
duration: task.duration ?? 5,
|
duration: task.duration ?? 5,
|
||||||
audio: task.outputAudio ?? true,
|
audio: task.outputAudio ?? true,
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
|
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
|
||||||
return {
|
return { model: task.model };
|
||||||
model: task.model,
|
|
||||||
messages: [{ role: 'user', content: task.prompt }],
|
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
stream: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function embeddingInput(prompt: string) {
|
function embeddingInput(prompt: string) {
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => {
|
|||||||
expect(html).toContain('任务取回接口');
|
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', () => {
|
it('documents async mode as a body-independent capability', () => {
|
||||||
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
|
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 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, Input, 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 { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
|
||||||
|
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
|
||||||
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
||||||
|
|
||||||
interface ApiDocItem {
|
interface ApiDocItem {
|
||||||
@@ -90,7 +91,7 @@ export function ApiDocsPage(props: {
|
|||||||
onCreateApiKey: () => void;
|
onCreateApiKey: () => void;
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
onDocSectionChange: (value: ApiDocSection) => void;
|
onDocSectionChange: (value: ApiDocSection) => void;
|
||||||
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitTask: (event: FormEvent<HTMLFormElement>, options?: RunTaskOptions) => void;
|
||||||
onTaskFormChange: (value: TaskForm) => void;
|
onTaskFormChange: (value: TaskForm) => void;
|
||||||
}) {
|
}) {
|
||||||
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
|
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
|
||||||
@@ -101,12 +102,12 @@ export function ApiDocsPage(props: {
|
|||||||
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
|
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
|
||||||
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
|
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
|
||||||
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
|
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
|
||||||
|
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
|
||||||
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(
|
const [submissionMode, setSubmissionMode] = useState<TaskSubmissionMode>('simulation');
|
||||||
() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'),
|
const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||||
[currentApiDoc?.key, props.taskForm],
|
const [bodyError, setBodyError] = useState('');
|
||||||
);
|
|
||||||
const runnerPath = currentApiDoc?.path
|
const runnerPath = currentApiDoc?.path
|
||||||
? isTaskRetrieveDoc
|
? isTaskRetrieveDoc
|
||||||
? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}')
|
? 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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
getOpsManagementSkillMetadata()
|
getOpsManagementSkillMetadata()
|
||||||
@@ -134,18 +141,55 @@ export function ApiDocsPage(props: {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
if (!runnerAvailable) {
|
if (!runnerAvailable) {
|
||||||
event.preventDefault();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!props.canRun) {
|
if (!props.canRun) {
|
||||||
event.preventDefault();
|
|
||||||
props.onLogin();
|
props.onLogin();
|
||||||
return;
|
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);
|
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) {
|
function handleDocClick(item: ApiDocItem) {
|
||||||
if (item.kind) {
|
if (item.kind) {
|
||||||
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
|
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}>
|
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
|
||||||
<header>
|
<header>
|
||||||
<strong>在线运行</strong>
|
<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} />
|
<Send size={14} />
|
||||||
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,6 +330,34 @@ export function ApiDocsPage(props: {
|
|||||||
)}
|
)}
|
||||||
{runnerAvailable ? (
|
{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">
|
<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)}>
|
||||||
@@ -304,14 +376,29 @@ export function ApiDocsPage(props: {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<label className="shLabel">
|
<div className="runnerBodyField">
|
||||||
请求 Body
|
<label className="shLabel">
|
||||||
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
请求 Body
|
||||||
</label>
|
<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} />
|
<Play size={15} />
|
||||||
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
{!props.canRun
|
||||||
|
? '登录后运行'
|
||||||
|
: props.coreState === 'loading'
|
||||||
|
? '运行中'
|
||||||
|
: submissionMode === 'simulation' || !runnerModeEnabled
|
||||||
|
? '运行测试'
|
||||||
|
: '真实提交'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -767,9 +854,9 @@ function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm
|
|||||||
return { ...current, kind, model: 'task' };
|
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'
|
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'
|
: task.kind === 'responses'
|
||||||
? {
|
? {
|
||||||
model: task.model,
|
model: task.model,
|
||||||
@@ -778,15 +865,13 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
|||||||
previous_response_id: task.previousResponseId || undefined,
|
previous_response_id: task.previousResponseId || undefined,
|
||||||
store: true,
|
store: true,
|
||||||
stream: false,
|
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 }
|
||||||
: 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 }
|
||||||
: 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 }
|
||||||
: task.kind === 'videos.generations'
|
: task.kind === 'videos.generations'
|
||||||
? {
|
? {
|
||||||
model: task.model,
|
model: task.model,
|
||||||
@@ -795,54 +880,23 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
|||||||
resolution: task.resolution ?? '720p',
|
resolution: task.resolution ?? '720p',
|
||||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||||
audio: task.outputAudio ?? true,
|
audio: task.outputAudio ?? true,
|
||||||
runMode: 'simulation',
|
|
||||||
simulation: true,
|
|
||||||
}
|
}
|
||||||
: section === 'pricing'
|
: section === 'pricing'
|
||||||
? { kind: 'chat.completions', model: 'gpt-4o-mini', messages: [{ role: 'user', content: '你好' }], max_tokens: 512 }
|
? { 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', size: '1024x1024' };
|
||||||
return JSON.stringify(body, null, 2);
|
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 {
|
try {
|
||||||
const body = JSON.parse(value) as {
|
const body = JSON.parse(value) as unknown;
|
||||||
image?: string;
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
aspect_ratio?: string;
|
return { body: null, error: '请求 Body 必须是 JSON 对象。' };
|
||||||
audio?: boolean;
|
}
|
||||||
content?: Array<{ text?: string; type?: string }>;
|
return { body: body as Record<string, unknown>, error: '' };
|
||||||
duration?: number;
|
} catch (error) {
|
||||||
instructions?: string;
|
const detail = error instanceof SyntaxError ? error.message : '无法解析 JSON';
|
||||||
mask?: string;
|
return { body: null, error: `JSON 格式有误:${detail}` };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,20 +1152,3 @@ function splitLines(value: string) {
|
|||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean);
|
.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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -322,6 +322,84 @@
|
|||||||
padding: 0 16px 16px;
|
padding: 0 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runnerModeField {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeLabel {
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeToggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeToggle button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeToggle button:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeToggle button[data-active='true'] {
|
||||||
|
border-color: #d8dee8;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeToggle button:last-child[data-active='true'] {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeHint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerModeHint[data-mode='production'] {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerBodyError {
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerBodyField {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsRunner .shTextarea[aria-invalid='true'] {
|
||||||
|
border-color: #f04438;
|
||||||
|
box-shadow: 0 0 0 2px rgba(240, 68, 56, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.docsRunnerUnavailable {
|
.docsRunnerUnavailable {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 TaskSubmissionMode = 'simulation' | 'production';
|
||||||
export type TaskKind =
|
export type TaskKind =
|
||||||
| 'chat.completions'
|
| 'chat.completions'
|
||||||
| 'responses'
|
| 'responses'
|
||||||
|
|||||||
Reference in New Issue
Block a user