From b04a7d9d3d7cc05c65b37a97aef08ef92f32b3cc Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 21 Jul 2026 23:52:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=A1=B5=E7=9C=9F=E5=AE=9E=E6=8F=90=E4=BA=A4=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/App.tsx | 11 +- apps/web/src/api.test.ts | 43 +++++ apps/web/src/lib/run-task.ts | 175 +++++++++++---------- apps/web/src/pages/ApiDocsPage.test.tsx | 11 ++ apps/web/src/pages/ApiDocsPage.tsx | 201 ++++++++++++++---------- apps/web/src/styles/api-docs.css | 78 +++++++++ apps/web/src/types.ts | 1 + 7 files changed, 348 insertions(+), 172 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0877d36..9740241 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -133,7 +133,7 @@ import { startOIDCLogin, startOIDCLogout, } from './lib/oidc'; -import { runTask } from './lib/run-task'; +import { runTask, type RunTaskOptions } from './lib/run-task'; import { AdminPage } from './pages/AdminPage'; import { ApiDocsPage } from './pages/ApiDocsPage'; import { HomePage } from './pages/HomePage'; @@ -1149,7 +1149,7 @@ export function App() { } } - async function submitTask(event: FormEvent) { + async function submitTask(event: FormEvent, options: RunTaskOptions = {}) { event.preventDefault(); const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : ''; const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id; @@ -1160,11 +1160,12 @@ export function App() { setCoreState('loading'); setCoreMessage(''); try { - const response = await runTask(credential, taskForm); + const response = await runTask(credential, taskForm, options); + const completionMessage = response.submissionMode === 'simulation' ? '完成测试模式运行' : '完成真实提交'; if (response.localOnly) { setTaskResult(response.task); setCoreState('ready'); - setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`); return; } const syncTask = (detail: GatewayTask) => { @@ -1176,7 +1177,7 @@ export function App() { setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]); invalidateDataKeys('tasks', 'wallet', 'walletTransactions'); setCoreState('ready'); - setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`); } catch (err) { setCoreState('error'); setCoreMessage(err instanceof Error ? err.message : '测试任务失败'); diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 0aa6b2e..8e4c220 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -16,6 +16,7 @@ import { retireIdentityPairingSecurityEventConflict, validateIdentityRevision, } from './api'; +import { applyTaskSubmissionMode, runTask } from './lib/run-task'; describe('local login transport', () => { afterEach(() => { @@ -316,4 +317,46 @@ describe('API documentation runner transports', () => { expect(url).toContain('/api/v1/tasks/task-123'); 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, + }); + }); }); diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index a5fd6ea..6efa599 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -9,101 +9,97 @@ import { createVideoGenerationTask, getAPITask, } from '../api'; -import type { TaskForm } from '../types'; +import type { TaskForm, TaskSubmissionMode } from '../types'; export interface RunTaskResponse { localOnly?: boolean; next?: Record; + submissionMode: TaskSubmissionMode; task: GatewayTask; } -export async function runTask(token: string, task: TaskForm): Promise { +export interface RunTaskOptions { + requestBody?: Record; + submissionMode?: TaskSubmissionMode; +} + +const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const; + +export function applyTaskSubmissionMode( + input: Record, + submissionMode: TaskSubmissionMode, +): Record { + 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 { + 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') { - const result = await createCompatibleChatCompletion(token, { - model: task.model, - runMode: 'simulation', - simulation: true, - stream: false, - messages: [{ role: 'user', content: task.prompt }], - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createCompatibleChatCompletion( + token, + requestBody as Parameters[1], + ); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } 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) }; + const result = await createResponse(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'embeddings') { - const result = await createEmbedding(token, { - model: task.model, - input: embeddingInput(task.prompt), - dimensions: task.dimensions, - runMode: 'simulation', - simulation: true, - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createEmbedding(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'reranks') { - const result = await createRerank(token, { - model: task.model, - query: task.prompt, - documents: rerankDocuments(task.documents), - top_n: task.topN, - runMode: 'simulation', - simulation: true, - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createRerank(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'images.generations') { - return createImageGenerationTask(token, { - model: task.model, - prompt: task.prompt, - quality: 'medium', - runMode: 'simulation', - simulation: true, - size: '1024x1024', - }); + const response = await createImageGenerationTask( + token, + requestBody as Parameters[1], + ); + return { ...response, submissionMode }; } if (task.kind === 'images.edits') { - return createImageEditTask(token, { - model: task.model, - prompt: task.prompt, - image: task.image, - mask: task.mask, - runMode: 'simulation', - simulation: true, - }); + const response = await createImageEditTask(token, requestBody as Parameters[1]); + return { ...response, submissionMode }; } 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, - }); + const response = await createVideoGenerationTask( + token, + requestBody as unknown as Parameters[1], + ); + return { ...response, submissionMode }; } 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) }; + return { + localOnly: true, + submissionMode: 'production', + task: compatibleTask(task, result as unknown as Record, requestBody, 'production'), + }; } throw new Error(`Unsupported task kind: ${task.kind}`); } -function compatibleTask(task: TaskForm, result: Record): GatewayTask { +function compatibleTask( + task: TaskForm, + result: Record, + requestBody: Record, + submissionMode: TaskSubmissionMode, +): GatewayTask { const now = new Date().toISOString(); return { id: `docs-${task.kind}-${Date.now()}`, @@ -111,26 +107,31 @@ function compatibleTask(task: TaskForm, result: Record): Gatewa createdAt: now, finishedAt: now, kind: task.kind, - model: task.model, + model: typeof requestBody.model === 'string' ? requestBody.model : task.model, modelType: modelTypeForKind(task.kind), - request: requestSnapshot(task), + request: requestBody, result, - runMode: 'simulation', + runMode: submissionMode, status: 'succeeded', updatedAt: now, userId: 'docs-runner', }; } -function requestSnapshot(task: TaskForm): Record { +function defaultRequestBody(task: TaskForm): Record { + if (task.kind === 'chat.completions') { + return { + model: task.model, + messages: [{ role: 'user', content: task.prompt }], + stream: false, + }; + } 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, }; @@ -140,8 +141,6 @@ function requestSnapshot(task: TaskForm): Record { model: task.model, input: embeddingInput(task.prompt), dimensions: task.dimensions, - runMode: 'simulation', - simulation: true, }; } if (task.kind === 'reranks') { @@ -150,8 +149,22 @@ function requestSnapshot(task: TaskForm): Record { query: task.prompt, documents: rerankDocuments(task.documents), 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') { @@ -162,18 +175,10 @@ function requestSnapshot(task: TaskForm): Record { resolution: task.resolution ?? '720p', duration: task.duration ?? 5, audio: task.outputAudio ?? true, - runMode: 'simulation', - simulation: true, }; } if (task.kind === 'tasks.retrieve') return { taskId: task.taskId }; - return { - model: task.model, - messages: [{ role: 'user', content: task.prompt }], - runMode: 'simulation', - simulation: true, - stream: false, - }; + return { model: task.model }; } function embeddingInput(prompt: string) { diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx index f3550ce..521fc4f 100644 --- a/apps/web/src/pages/ApiDocsPage.test.tsx +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -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: '你好' }); diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index 9cb76bd..72f0aa0 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -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) => void; + onSubmitTask: (event: FormEvent, 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('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) { + 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 ?
在线运行 - @@ -286,6 +330,34 @@ export function ApiDocsPage(props: { )} {runnerAvailable ? ( <> + {runnerModeEnabled && ( +
+ 运行模式 +
+ + +
+

+ {submissionMode === 'simulation' + ? '不触达真实供应商,不消耗上游额度。' + : '请求将真实提交给供应商,可能产生费用。'} +

+
+ )}