From fa5fed56d94141be140e00f86fd5cfc2efd36227 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Sat, 25 Apr 2026 16:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20API=20=E6=B5=8B=E8=AF=95=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E4=B8=BA=20=E6=93=8D=E7=BB=83=E5=9C=BA?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9B=B4=E5=A4=9A=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了对多个 API 参数的支持,包括 temperature、top_p、frequency_penalty 等。 - 改进了请求体的构建逻辑,支持更复杂的请求参数。 - 增加了对频道的支持,确保 API 调用时的频道权限验证。 - 优化了消息处理和响应显示,支持流式响应和消息编辑功能。 --- frontend/src/api/client.ts | 75 +- frontend/src/components/ui/autocomplete.tsx | 4 +- frontend/src/i18n/locales/en.json | 41 +- frontend/src/i18n/locales/zh-CN.json | 41 +- frontend/src/i18n/locales/zh-TW.json | 41 +- frontend/src/lib/channel-models.ts | 23 +- frontend/src/pages/ApiTest.tsx | 1101 ++++++++++++++----- src/providers/claude-to-openai-proxy.ts | 3 + src/providers/shared/channel-resolver.ts | 15 + 9 files changed, 1031 insertions(+), 313 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f4d9cfe..da2cbae 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -38,6 +38,19 @@ type RequestBehavior = { skipUnauthorizedHandler?: boolean } +type TestApiOptions = { + channelKey?: string +} + +type TestApiStreamOptions = TestApiOptions & { + signal?: AbortSignal + onChunk: (chunk: string) => void +} + +type TestApiStreamResult = { + status: number +} + const isAdminEndpoint = (endpoint: string): boolean => endpoint.startsWith('/api/admin/') export function setErrorHandlers(handlers: { @@ -290,15 +303,20 @@ export const apiClient = { ), // API Test - 使用自定义 token,不走通用拦截器 - testApi: async (endpoint: string, token: string, body: unknown): Promise => { + testApi: async (endpoint: string, token: string, body: unknown, options: TestApiOptions = {}): Promise => { const url = `${BASE_URL}${endpoint}` + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + } + if (options.channelKey) { + headers['x-channel-key'] = options.channelKey + } + const response = await fetch(url, { method: 'POST', credentials: 'omit', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, + headers, body: JSON.stringify(body), }) @@ -320,6 +338,53 @@ export const apiClient = { return responseInterceptor(response) }, + testApiStream: async (endpoint: string, token: string, body: unknown, options: TestApiStreamOptions): Promise => { + const url = `${BASE_URL}${endpoint}` + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + } + if (options.channelKey) { + headers['x-channel-key'] = options.channelKey + } + + const response = await fetch(url, { + method: 'POST', + credentials: 'omit', + signal: options.signal, + headers, + body: JSON.stringify(body), + }) + + if (!response.ok) { + await responseInterceptor(response) + } + + if (!response.body) { + throw new ApiError('Response stream is not available', response.status) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + options.onChunk(decoder.decode(value, { stream: true })) + } + + const tail = decoder.decode() + if (tail) { + options.onChunk(tail) + } + } finally { + reader.releaseLock() + } + + return { status: response.status } + }, + // Auth check checkAuth: () => request('/api/admin/channel', { method: 'GET' }), diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx index 75d92bd..a0b3502 100644 --- a/frontend/src/components/ui/autocomplete.tsx +++ b/frontend/src/components/ui/autocomplete.tsx @@ -143,8 +143,8 @@ export const AutoCompleteInput = React.forwardRef event.preventDefault()} onClick={() => handleSelect(option.value)} > - {label} - {showDescription && {option.description}} + {label} + {showDescription && {option.description}} ); })} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c630112..f433fa2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -51,7 +51,7 @@ "tokens": "Tokens", "pricing": "Pricing", "usageLogs": "Usage Logs", - "apiTest": "API Test", + "apiTest": "Playground", "settings": "Settings", "theme": "Theme", "language": "Language", @@ -381,25 +381,50 @@ "detailChannel": "Channel" }, "apiTest": { - "title": "API Test", - "description": "Test API connections and configurations", + "title": "Playground", + "description": "Debug multi-turn conversations with a selected token, model, and channel", "request": "Request", "response": "Response", "endpoint": "Endpoint", "apiToken": "API Token", "model": "Model", "modelPlaceholder": "Select or enter model name", + "selectTokenFirst": "Select an API token first", + "modelRequired": "Please select a model", + "channel": "Channel", + "autoChannel": "Auto-select channel", + "noMatchingChannels": "No matching channels", "requestBody": "Request Body", - "send": "Send Request", + "playgroundParams": "Playground Parameters", + "temperature": "Temperature", + "topP": "Top P", + "frequencyPenalty": "Frequency Penalty", + "presencePenalty": "Presence Penalty", + "stream": "Stream", + "maxTokens": "Max Tokens", + "seed": "Seed", + "paramsUnsupported": "This endpoint does not support visual parameters. You can edit the custom request body directly.", + "conversation": "Conversation", + "conversationEmpty": "Select an API token and model, then send your first message.", + "messagePlaceholder": "Type a message, Ctrl/⌘ + Enter to send", + "user": "User", + "assistant": "Assistant", + "editMessage": "Edit", + "retryMessage": "Retry", + "viewRawResponse": "Response Body", + "rawResponse": "Raw Response Body", + "emptyReadableResponse": "The response has no readable text content. Open the raw response body for details.", + "responseFailed": "Failed", + "streaming": "Streaming", + "stopGeneration": "Stop Generation", + "generationStopped": "Generation stopped", + "send": "Send", "sending": "Sending...", "noMatchingTokens": "No matching tokens", "noMatchingModels": "No matching models", "tokenRequired": "Please enter an API token", "bodyJsonError": "Invalid request body JSON", - "copyResponse": "Copy response", - "audioSuccess": "Audio response received", - "downloadAudio": "Download Audio", - "responseEmpty": "Send a request to see the response here" + "copyResponse": "Copy response" }, "settings": { "title": "Settings", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 48ae2ff..b421259 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -51,7 +51,7 @@ "tokens": "令牌管理", "pricing": "定价管理", "usageLogs": "使用日志", - "apiTest": "API 测试", + "apiTest": "操练场", "settings": "系统设置", "theme": "主题", "language": "语言", @@ -381,25 +381,50 @@ "detailChannel": "渠道" }, "apiTest": { - "title": "API 测试", - "description": "测试 API 连接和配置", + "title": "操练场", + "description": "用指定令牌、模型和渠道进行多轮会话调试", "request": "请求", "response": "响应", "endpoint": "端点", "apiToken": "API 令牌", "model": "模型", "modelPlaceholder": "选择或输入模型名称", + "selectTokenFirst": "请先选择 API 令牌", + "modelRequired": "请选择模型", + "channel": "渠道", + "autoChannel": "自动选择渠道", + "noMatchingChannels": "没有匹配的渠道", "requestBody": "请求体", - "send": "发送请求", + "playgroundParams": "操练参数", + "temperature": "Temperature", + "topP": "Top P", + "frequencyPenalty": "Frequency Penalty", + "presencePenalty": "Presence Penalty", + "stream": "流式输出", + "maxTokens": "Max Tokens", + "seed": "Seed", + "paramsUnsupported": "当前端点不支持可视化调参,可直接编辑自定义请求体。", + "conversation": "会话", + "conversationEmpty": "选择 API 令牌和模型后,发送第一条消息开始操练。", + "messagePlaceholder": "输入消息,Ctrl/⌘ + Enter 发送", + "user": "用户", + "assistant": "助手", + "editMessage": "编辑", + "retryMessage": "重发", + "viewRawResponse": "响应体", + "rawResponse": "真实响应体", + "emptyReadableResponse": "响应中没有可直接展示的文字内容,请查看真实响应体。", + "responseFailed": "失败", + "streaming": "流式输出中", + "stopGeneration": "停止生成", + "generationStopped": "已停止生成", + "send": "发送", "sending": "发送中...", "noMatchingTokens": "没有匹配的令牌", "noMatchingModels": "没有匹配的模型", "tokenRequired": "请输入 API 令牌", "bodyJsonError": "请求体 JSON 格式错误", - "copyResponse": "复制响应", - "audioSuccess": "音频返回成功", - "downloadAudio": "下载音频", - "responseEmpty": "发送请求后在此查看响应" + "copyResponse": "复制响应" }, "settings": { "title": "系统设置", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 1c2db8d..e52ea7c 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -51,7 +51,7 @@ "tokens": "令牌管理", "pricing": "定價管理", "usageLogs": "使用日誌", - "apiTest": "API 測試", + "apiTest": "操練場", "settings": "系統設定", "theme": "主題", "language": "語言", @@ -381,25 +381,50 @@ "detailChannel": "渠道" }, "apiTest": { - "title": "API 測試", - "description": "測試 API 連接和設定", + "title": "操練場", + "description": "用指定令牌、模型和渠道進行多輪會話調試", "request": "請求", "response": "回應", "endpoint": "端點", "apiToken": "API 令牌", "model": "模型", "modelPlaceholder": "選擇或輸入模型名稱", + "selectTokenFirst": "請先選擇 API 令牌", + "modelRequired": "請選擇模型", + "channel": "渠道", + "autoChannel": "自動選擇渠道", + "noMatchingChannels": "沒有匹配的渠道", "requestBody": "請求體", - "send": "傳送請求", + "playgroundParams": "操練參數", + "temperature": "Temperature", + "topP": "Top P", + "frequencyPenalty": "Frequency Penalty", + "presencePenalty": "Presence Penalty", + "stream": "串流輸出", + "maxTokens": "Max Tokens", + "seed": "Seed", + "paramsUnsupported": "目前端點不支援視覺化調參,可直接編輯自訂請求體。", + "conversation": "會話", + "conversationEmpty": "選擇 API 令牌和模型後,傳送第一條訊息開始操練。", + "messagePlaceholder": "輸入訊息,Ctrl/⌘ + Enter 傳送", + "user": "使用者", + "assistant": "助手", + "editMessage": "編輯", + "retryMessage": "重發", + "viewRawResponse": "響應體", + "rawResponse": "真實響應體", + "emptyReadableResponse": "響應中沒有可直接展示的文字內容,請查看真實響應體。", + "responseFailed": "失敗", + "streaming": "串流輸出中", + "stopGeneration": "停止生成", + "generationStopped": "已停止生成", + "send": "傳送", "sending": "傳送中...", "noMatchingTokens": "沒有匹配的令牌", "noMatchingModels": "沒有匹配的模型", "tokenRequired": "請輸入 API 令牌", "bodyJsonError": "請求體 JSON 格式錯誤", - "copyResponse": "複製回應", - "audioSuccess": "音訊返回成功", - "downloadAudio": "下載音訊", - "responseEmpty": "傳送請求後在此查看回應" + "copyResponse": "複製回應" }, "settings": { "title": "系統設定", diff --git a/frontend/src/lib/channel-models.ts b/frontend/src/lib/channel-models.ts index 00b674c..05e7d50 100644 --- a/frontend/src/lib/channel-models.ts +++ b/frontend/src/lib/channel-models.ts @@ -113,18 +113,35 @@ export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] = return Array.from(modelNames).sort() } -export const getModelNamesForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): string[] => { +export const getChannelsForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): Channel[] => { const matchedToken = tokens.find((token) => token.key === tokenKey) if (!matchedToken) { - return getUniqueModelNamesFromChannels(channels) + return [] } const tokenConfig = parseTokenConfig(matchedToken) const allowedChannelKeys = tokenConfig.channel_keys || [] - const targetChannels = allowedChannelKeys.length === 0 + + return allowedChannelKeys.length === 0 ? channels : channels.filter((channel) => allowedChannelKeys.includes(channel.key)) +} +export const channelSupportsModel = (channel: Channel, modelName: string): boolean => { + if (!modelName) { + return true + } + + const config = parseChannelConfig(channel) + return getChannelModels(config) + .filter((model) => isChannelModelEnabled(model)) + .some((model) => model.name === modelName) +} + +export const getModelNamesForChannels = (channels: Channel[]): string[] => getUniqueModelNamesFromChannels(channels) + +export const getModelNamesForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): string[] => { + const targetChannels = getChannelsForToken(tokenKey, tokens, channels) return getUniqueModelNamesFromChannels(targetChannels) } diff --git a/frontend/src/pages/ApiTest.tsx b/frontend/src/pages/ApiTest.tsx index 2590fb6..fa471a4 100644 --- a/frontend/src/pages/ApiTest.tsx +++ b/frontend/src/pages/ApiTest.tsx @@ -1,97 +1,204 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { apiClient } from '@/api/client' -import { AudioTestResponse, Channel, TestResponse, Token } from '@/types' +import { Channel, Token } from '@/types' import { AutoCompleteInput, type AutoCompleteOption } from '@/components/ui/autocomplete' -import { Send, Clock, CheckCircle, XCircle, Copy, Download } from 'lucide-react' +import { Bot, CheckCircle, ChevronDown, ChevronRight, Clock, Code2, Copy, Edit3, RefreshCw, Save, Send, Square, User, X } from 'lucide-react' import { PageContainer } from '@/components/ui/page-container' import { cn, copyToClipboard } from '@/lib/utils' -import { getModelNamesForToken, parseTokenConfig } from '@/lib/channel-models' +import { + channelSupportsModel, + getChannelModels, + getChannelsForToken, + getModelNamesForChannels, + parseChannelConfig, + parseTokenConfig, +} from '@/lib/channel-models' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { useTranslation } from 'react-i18next' -const requestTemplates: Record = { - '/v1/chat/completions': { - model: 'gpt-3.5-turbo', - messages: [ - { - role: 'user', - content: 'Hello, how are you?', - }, - ], - temperature: 0.7, - max_tokens: 100, - }, - '/v1/messages': { - model: 'claude-3-5-sonnet-20241022', - max_tokens: 1024, - messages: [ - { - role: 'user', - content: 'Hello, Claude!', - }, - ], - }, - '/v1/responses': { - model: 'gpt-4o-mini', - input: 'Hello, Responses API!', - }, - '/v1/audio/speech': { - model: 'gpt-4o-mini-tts', - input: 'The quick brown fox jumped over the lazy dog', - voice: 'alloy', - }, - '/v1/completions': { - model: 'gpt-3.5-turbo-instruct', - prompt: 'Once upon a time', - max_tokens: 100, - temperature: 0.7, - }, +type RequestBodyShape = Record +type ChatEndpoint = '/v1/chat/completions' | '/v1/messages' +type MessageRole = 'user' | 'assistant' +type MessageStatus = 'idle' | 'streaming' | 'success' | 'error' + +type PlaygroundMessage = { + id: string + role: MessageRole + content: string + createdAt: number + updatedAt?: number + status: MessageStatus + error?: string + model?: string + channelKey?: string + rawResponse?: unknown + rawResponseText?: string } -const buildRequestBody = (endpoint: string, model = '') => { - const template = requestTemplates[endpoint] || {} - const { model: _templateModel, ...rest } = template +type SendConversationOptions = { + baseMessages?: PlaygroundMessage[] + userMessage?: PlaygroundMessage +} - return JSON.stringify( - model ? { ...rest, model } : rest, - null, - 2 - ) +const chatEndpoints: ChatEndpoint[] = ['/v1/chat/completions', '/v1/messages'] + +const playgroundParamKeys = [ + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty', + 'stream', + 'max_tokens', + 'seed', +] as const + +type PlaygroundParamKey = typeof playgroundParamKeys[number] +type PlaygroundParams = Partial> + +type ParamDefinition = { + key: PlaygroundParamKey + type: 'number' | 'boolean' + min?: number + max?: number + step?: number + translationKey: string +} + +const paramDefinitions: ParamDefinition[] = [ + { key: 'temperature', type: 'number', min: 0, max: 2, step: 0.1, translationKey: 'apiTest.temperature' }, + { key: 'top_p', type: 'number', min: 0, max: 1, step: 0.1, translationKey: 'apiTest.topP' }, + { key: 'frequency_penalty', type: 'number', min: -2, max: 2, step: 0.1, translationKey: 'apiTest.frequencyPenalty' }, + { key: 'presence_penalty', type: 'number', min: -2, max: 2, step: 0.1, translationKey: 'apiTest.presencePenalty' }, + { key: 'stream', type: 'boolean', translationKey: 'apiTest.stream' }, + { key: 'max_tokens', type: 'number', min: 1, step: 1, translationKey: 'apiTest.maxTokens' }, + { key: 'seed', type: 'number', step: 1, translationKey: 'apiTest.seed' }, +] + +const endpointParamKeys: Record = { + '/v1/chat/completions': [...playgroundParamKeys], + '/v1/messages': ['temperature', 'top_p', 'stream', 'max_tokens'], +} + +const initialParams: PlaygroundParams = { + temperature: 0.7, + stream: true, + max_tokens: 1024, +} + +const createMessageId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +const extractParamsFromBody = (body: RequestBodyShape | null | undefined): PlaygroundParams => { + const params: PlaygroundParams = {} + + for (const key of playgroundParamKeys) { + const value = body?.[key] + if (typeof value === 'number' || typeof value === 'boolean') { + params[key] = value + } + } + + return params +} + +const buildAdvancedBody = (params: PlaygroundParams) => { + const body: RequestBodyShape = {} + for (const key of playgroundParamKeys) { + const value = params[key] + if (value !== undefined && value !== '') { + body[key] = value + } + } + return JSON.stringify(body, null, 2) +} + +const stringifyRawResponse = (value: unknown): string => { + if (typeof value === 'string') { + return value + } + return JSON.stringify(value, null, 2) +} + +const parseAssistantContent = (endpoint: ChatEndpoint, result: unknown): string => { + if (!result || typeof result !== 'object') { + return typeof result === 'string' ? result : JSON.stringify(result, null, 2) + } + + const payload = result as Record + if (endpoint === '/v1/messages') { + const blocks = Array.isArray(payload.content) ? payload.content : [] + const text = blocks + .map((block) => { + if (typeof block === 'object' && block !== null && 'text' in block && typeof block.text === 'string') { + return block.text + } + return '' + }) + .filter(Boolean) + .join('') + return text || JSON.stringify(result, null, 2) + } + + const choice = Array.isArray(payload.choices) ? payload.choices[0] : undefined + if (typeof choice === 'object' && choice !== null) { + if ('message' in choice && typeof choice.message === 'object' && choice.message !== null && 'content' in choice.message && typeof choice.message.content === 'string') { + return choice.message.content + } + if ('text' in choice && typeof choice.text === 'string') { + return choice.text + } + } + + return JSON.stringify(result, null, 2) } export function ApiTest() { const { t } = useTranslation() - const [endpoint, setEndpoint] = useState('/v1/chat/completions') + const { addToast } = useToast() + const abortControllerRef = useRef(null) + const messageListRef = useRef(null) + + const [endpoint, setEndpoint] = useState('/v1/chat/completions') const [apiToken, setApiToken] = useState('') const [modelValue, setModelValue] = useState('') - const [requestBody, setRequestBody] = useState(buildRequestBody('/v1/chat/completions')) + const [channelKey, setChannelKey] = useState('') + const [playgroundParams, setPlaygroundParams] = useState(initialParams) + const [requestBody, setRequestBody] = useState(buildAdvancedBody(initialParams)) + const [requestBodyOpen, setRequestBodyOpen] = useState(false) + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState('') + const [editingMessageId, setEditingMessageId] = useState(null) + const [editingContent, setEditingContent] = useState('') + const [rawResponseMessage, setRawResponseMessage] = useState(null) const [isLoading, setIsLoading] = useState(false) - const [response, setResponse] = useState(null) const [responseTime, setResponseTime] = useState(0) const [statusCode, setStatusCode] = useState(null) - - const { addToast } = useToast() - const [tokens, setTokens] = useState([]) const [channels, setChannels] = useState([]) - const isAudioResponse = (value: TestResponse | { error: string } | null): value is AudioTestResponse => { - return !!value && value.object === 'audio' && typeof value.url === 'string' - } - useEffect(() => { return () => { - if (isAudioResponse(response)) { - URL.revokeObjectURL(response.url) - } + abortControllerRef.current?.abort() } - }, [response]) + }, []) useEffect(() => { const loadOptions = async () => { @@ -104,111 +211,54 @@ export function ApiTest() { setTokens((tokenResponse.data as Token[]) || []) setChannels((channelResponse.data as Channel[]) || []) } catch (error) { - console.error('Failed to load API test options:', error) + console.error('Failed to load playground options:', error) } } loadOptions() }, []) - const availableModels = useMemo( - () => getModelNamesForToken(apiToken, tokens, channels), + useEffect(() => { + messageListRef.current?.scrollTo({ + top: messageListRef.current.scrollHeight, + behavior: 'smooth', + }) + }, [messages]) + + const tokenChannels = useMemo( + () => apiToken ? getChannelsForToken(apiToken, tokens, channels).filter((channel) => parseChannelConfig(channel).enabled !== false) : [], [apiToken, tokens, channels] ) - const handleEndpointChange = (newEndpoint: string) => { - setEndpoint(newEndpoint) - setModelValue('') - setRequestBody(buildRequestBody(newEndpoint)) - } - - const handleCopyResponse = async () => { - if (response) { - try { - const payload = isAudioResponse(response) - ? { - object: response.object, - contentType: response.contentType, - size: response.size, - filename: response.filename, - } - : response - await copyToClipboard(JSON.stringify(payload, null, 2)) - addToast(t('common.copiedToClipboard'), 'success') - } catch { - addToast(t('common.copyFailed'), 'error') - } - } - } - - const updateRequestBodyModel = (nextModel: string) => { - setModelValue(nextModel) - - try { - const parsedBody = JSON.parse(requestBody) - if (nextModel) { - parsedBody.model = nextModel - } else { - delete parsedBody.model - } - setRequestBody(JSON.stringify(parsedBody, null, 2)) - } catch { - setRequestBody(buildRequestBody(endpoint, nextModel)) - } - } - - const handleRequestBodyChange = (value: string) => { - setRequestBody(value) - - try { - const parsedBody = JSON.parse(value) - if (typeof parsedBody?.model === 'string') { - setModelValue(parsedBody.model) - } else { - setModelValue('') - } - } catch { - // Keep the current model selection while the JSON is temporarily invalid. - } - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const selectedChannel = useMemo( + () => tokenChannels.find((channel) => channel.key === channelKey), + [channelKey, tokenChannels] + ) + const availableModelNames = useMemo(() => { if (!apiToken) { - addToast(t('apiTest.tokenRequired'), 'error') - return + return [] } - - let body: any - try { - body = JSON.parse(requestBody) - } catch { - addToast(t('apiTest.bodyJsonError'), 'error') - return + if (selectedChannel) { + const config = parseChannelConfig(selectedChannel) + return getChannelModels(config) + .filter((model) => model.enabled !== false) + .map((model) => model.name) + .sort() } + return getModelNamesForChannels(tokenChannels) + }, [apiToken, selectedChannel, tokenChannels]) - setIsLoading(true) - setResponse(null) - setStatusCode(null) + const availableChannels = useMemo( + () => tokenChannels.filter((channel) => channelSupportsModel(channel, modelValue)), + [modelValue, tokenChannels] + ) - const startTime = Date.now() - - try { - const result = await apiClient.testApi(endpoint, apiToken, body) - const endTime = Date.now() - setResponseTime(endTime - startTime) - setResponse(result) - setStatusCode(200) - } catch (error: any) { - const endTime = Date.now() - setResponseTime(endTime - startTime) - setStatusCode(error.status || 500) - setResponse({ error: error.message }) - } finally { - setIsLoading(false) - } - } + const enabledParamKeys = useMemo(() => endpointParamKeys[endpoint], [endpoint]) + const visibleParamDefinitions = useMemo( + () => paramDefinitions.filter((param) => enabledParamKeys.includes(param.key)), + [enabledParamKeys] + ) const tokenOptions: AutoCompleteOption[] = tokens.map((token) => { const tokenConfig = parseTokenConfig(token) @@ -221,160 +271,653 @@ export function ApiTest() { } }) - const modelOptions: AutoCompleteOption[] = availableModels.map((model) => ({ - value: model, - })) + const modelOptions: AutoCompleteOption[] = availableModelNames.map((model) => ({ value: model })) + + const channelOptions: AutoCompleteOption[] = availableChannels.map((channel) => { + const config = parseChannelConfig(channel) + return { + value: channel.key, + label: config.name || channel.key, + description: channel.key, + keywords: [channel.key, config.name || '', config.type || ''], + } + }) + + const handleTokenChange = (nextToken: string) => { + setApiToken(nextToken) + setModelValue('') + setChannelKey('') + } + + const handleModelChange = (nextModel: string) => { + setModelValue(nextModel) + if (channelKey) { + const channel = tokenChannels.find((item) => item.key === channelKey) + if (channel && !channelSupportsModel(channel, nextModel)) { + setChannelKey('') + } + } + } + + const handleChannelChange = (nextChannelKey: string) => { + setChannelKey(nextChannelKey) + if (!nextChannelKey || !modelValue) { + return + } + + const channel = tokenChannels.find((item) => item.key === nextChannelKey) + if (channel && !channelSupportsModel(channel, modelValue)) { + setModelValue('') + } + } + + const syncParamsFromRequestBody = (jsonText: string): RequestBodyShape => { + const parsedBody = JSON.parse(jsonText) as RequestBodyShape + setPlaygroundParams((current) => ({ + ...current, + ...extractParamsFromBody(parsedBody), + })) + return parsedBody + } + + const applyParamToRequestBody = (key: PlaygroundParamKey, value: number | boolean | undefined) => { + setPlaygroundParams((current) => ({ + ...current, + [key]: value, + })) + + try { + const parsedBody = JSON.parse(requestBody) as RequestBodyShape + if (value === undefined) { + delete parsedBody[key] + } else { + parsedBody[key] = value + } + setRequestBody(JSON.stringify(parsedBody, null, 2)) + } catch { + // Keep parameter state while the user fixes advanced JSON. + } + } + + const handleNumberParamChange = (key: PlaygroundParamKey, value: string) => { + if (value === '') { + applyParamToRequestBody(key, undefined) + return + } + + const numericValue = Number(value) + if (!Number.isNaN(numericValue)) { + applyParamToRequestBody(key, numericValue) + } + } + + const handleRequestBodyChange = (value: string) => { + setRequestBody(value) + try { + syncParamsFromRequestBody(value) + } catch { + // Advanced JSON can be temporarily invalid while typing. + } + } + + const normalizeAdvancedBody = (): RequestBodyShape => { + const parsedBody = requestBody.trim() ? JSON.parse(requestBody) as RequestBodyShape : {} + delete parsedBody.model + delete parsedBody.messages + delete parsedBody.stream + + for (const key of playgroundParamKeys) { + if (parsedBody[key] === undefined || parsedBody[key] === '') { + delete parsedBody[key] + } + } + + return parsedBody + } + + const buildRequestPayload = (conversationMessages: PlaygroundMessage[]) => { + const advancedBody = normalizeAdvancedBody() + const cleanMessages = conversationMessages + .filter((message) => message.role === 'user' || message.role === 'assistant') + .filter((message) => message.status !== 'error') + .filter((message) => message.content.trim().length > 0) + .map((message) => ({ + role: message.role, + content: message.content, + })) + + return { + ...advancedBody, + model: modelValue, + stream: playgroundParams.stream === true, + messages: cleanMessages, + } + } + + const extractStreamingTextFromData = (data: string) => { + if (!data || data === '[DONE]') { + return '' + } + + try { + const payload = JSON.parse(data) as Record + if (endpoint === '/v1/messages') { + const delta = payload.delta + if (typeof delta === 'object' && delta !== null && 'text' in delta && typeof delta.text === 'string') { + return delta.text + } + return '' + } + + const choices = payload.choices + const choice = Array.isArray(choices) ? choices[0] : undefined + if (typeof choice === 'object' && choice !== null && 'delta' in choice && typeof choice.delta === 'object' && choice.delta !== null && 'content' in choice.delta && typeof choice.delta.content === 'string') { + return choice.delta.content + } + } catch { + return '' + } + + return '' + } + + const extractStreamingText = (chunk: string, lineBuffer: string) => { + let text = '' + const combined = `${lineBuffer}${chunk}` + const lines = combined.split(/\r?\n/) + const nextLineBuffer = combined.endsWith('\n') || combined.endsWith('\r') ? '' : lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) { + continue + } + + text += extractStreamingTextFromData(trimmed.slice(5).trim()) + } + + return { text, lineBuffer: nextLineBuffer } + } + + const updateMessage = (messageId: string, updater: (message: PlaygroundMessage) => PlaygroundMessage) => { + setMessages((current) => current.map((message) => message.id === messageId ? updater(message) : message)) + } + + const sendConversation = async ({ baseMessages = messages, userMessage }: SendConversationOptions = {}) => { + if (!apiToken) { + addToast(t('apiTest.tokenRequired'), 'error') + return + } + if (!modelValue) { + addToast(t('apiTest.modelRequired'), 'error') + return + } + + const nextUserMessage = userMessage || { + id: createMessageId(), + role: 'user' as const, + content: inputValue.trim(), + createdAt: Date.now(), + status: 'success' as const, + model: modelValue, + channelKey: channelKey || undefined, + } + + if (!nextUserMessage.content.trim()) { + return + } + + let conversationMessages = [...baseMessages, nextUserMessage] + const assistantMessage: PlaygroundMessage = { + id: createMessageId(), + role: 'assistant', + content: '', + createdAt: Date.now(), + status: playgroundParams.stream === true ? 'streaming' : 'idle', + model: modelValue, + channelKey: channelKey || undefined, + } + conversationMessages = [...conversationMessages, assistantMessage] + + let body: RequestBodyShape + try { + body = buildRequestPayload(conversationMessages.filter((message) => message.id !== assistantMessage.id)) + } catch { + addToast(t('apiTest.bodyJsonError'), 'error') + return + } + + setInputValue('') + setMessages(conversationMessages) + setIsLoading(true) + setStatusCode(null) + + const startTime = Date.now() + const isStreaming = body.stream === true + + try { + if (isStreaming) { + const controller = new AbortController() + abortControllerRef.current = controller + let rawResponseText = '' + let streamLineBuffer = '' + const result = await apiClient.testApiStream(endpoint, apiToken, body, { + channelKey: channelKey || undefined, + signal: controller.signal, + onChunk: (chunk) => { + rawResponseText += chunk + const parsed = extractStreamingText(chunk, streamLineBuffer) + streamLineBuffer = parsed.lineBuffer + if (!parsed.text) { + updateMessage(assistantMessage.id, (message) => ({ + ...message, + rawResponseText, + status: 'streaming', + })) + return + } + updateMessage(assistantMessage.id, (message) => ({ + ...message, + content: `${message.content}${parsed.text}`, + rawResponseText, + status: 'streaming', + })) + }, + }) + setStatusCode(result.status) + updateMessage(assistantMessage.id, (message) => ({ + ...message, + content: message.content || t('apiTest.emptyReadableResponse'), + rawResponseText, + status: 'success', + })) + return + } + + updateMessage(assistantMessage.id, (message) => ({ ...message, status: 'idle' })) + const result = await apiClient.testApi(endpoint, apiToken, body, { channelKey: channelKey || undefined }) + setStatusCode(200) + updateMessage(assistantMessage.id, (message) => ({ + ...message, + content: parseAssistantContent(endpoint, result), + rawResponse: result, + status: 'success', + })) + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + updateMessage(assistantMessage.id, (message) => ({ ...message, status: 'error', error: t('apiTest.generationStopped') })) + return + } + + const status = typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number' + ? error.status + : 500 + const message = error instanceof Error ? error.message : String(error) + setStatusCode(status) + updateMessage(assistantMessage.id, (current) => ({ + ...current, + content: message, + rawResponse: { error: message, status }, + status: 'error', + error: message, + })) + } finally { + setResponseTime(Date.now() - startTime) + abortControllerRef.current = null + setIsLoading(false) + } + } + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + void sendConversation() + } + + const handleStopStreaming = () => { + abortControllerRef.current?.abort() + abortControllerRef.current = null + setIsLoading(false) + addToast(t('apiTest.generationStopped'), 'success') + } + + const startEditingMessage = (message: PlaygroundMessage) => { + setEditingMessageId(message.id) + setEditingContent(message.content) + } + + const saveEditedMessage = () => { + if (!editingMessageId) { + return + } + + setMessages((current) => { + const index = current.findIndex((message) => message.id === editingMessageId) + if (index === -1) { + return current + } + + const editedMessage = { + ...current[index], + content: editingContent, + updatedAt: Date.now(), + } + + return [...current.slice(0, index), editedMessage] + }) + setEditingMessageId(null) + setEditingContent('') + } + + const retryFromMessage = (messageId: string) => { + const index = messages.findIndex((message) => message.id === messageId) + if (index === -1) { + return + } + + const target = messages[index] + const userIndex = target.role === 'user' + ? index + : [...messages.slice(0, index)].map((message) => message.role).lastIndexOf('user') + + if (userIndex < 0) { + return + } + + const baseMessages = messages.slice(0, userIndex) + const userMessage = { + ...messages[userIndex], + id: createMessageId(), + createdAt: Date.now(), + model: modelValue, + channelKey: channelKey || undefined, + status: 'success' as const, + } + + void sendConversation({ baseMessages, userMessage }) + } + + const handleCopyMessages = async () => { + try { + await copyToClipboard(JSON.stringify(messages, null, 2)) + addToast(t('common.copiedToClipboard'), 'success') + } catch { + addToast(t('common.copyFailed'), 'error') + } + } + + const rawResponseContent = rawResponseMessage + ? rawResponseMessage.rawResponseText || stringifyRawResponse(rawResponseMessage.rawResponse) + : '' + + const handleCopyRawResponse = async () => { + if (!rawResponseContent) { + return + } + + try { + await copyToClipboard(rawResponseContent) + addToast(t('common.copiedToClipboard'), 'success') + } catch { + addToast(t('common.copyFailed'), 'error') + } + } return ( -
- {/* Request Panel */} + !open && setRawResponseMessage(null)}> + + + {t('apiTest.rawResponse')} + +
+            {rawResponseContent}
+          
+
+ +
+
+
+ +
- -
+ +
-

{t('apiTest.request')}

+

{t('apiTest.playgroundParams')}

-
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- +
+ + +
+ +
+
+ {visibleParamDefinitions.map((param) => ( +
+ + {param.type === 'boolean' ? ( +
+ {playgroundParams[param.key] === true ? t('common.enabled') : t('common.disabled')} + applyParamToRequestBody(param.key, checked)} + /> +
+ ) : ( + handleNumberParamChange(param.key, event.target.value)} + className="font-mono" + /> + )} +
+ ))} +
+
+ +
+ + {requestBodyOpen && (