diff --git a/controller/playground.go b/controller/playground.go index 501c4e156573..1c3dabc743c0 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -4,15 +4,18 @@ import ( "errors" "fmt" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" - relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) -func Playground(c *gin.Context) { +func Playground(c *gin.Context, relayFormat types.RelayFormat) { var newAPIError *types.NewAPIError defer func() { @@ -29,12 +32,6 @@ func Playground(c *gin.Context) { return } - relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAI, nil, nil) - if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) - return - } - userId := c.GetInt("id") // Write user context to ensure acceptUnsetRatio is available @@ -45,12 +42,28 @@ func Playground(c *gin.Context) { } userCache.WriteContext(c) + playgroundRequest := &dto.PlayGroundRequest{} + if err := common.UnmarshalBodyReusable(c, playgroundRequest); err != nil { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + return + } + + usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + if playgroundRequest.Group != "" { + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + if playgroundRequest.Group != userGroup && !service.GroupInUserUsableGroups(userGroup, playgroundRequest.Group) { + newAPIError = types.NewError(errors.New("group access denied"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry()) + return + } + usingGroup = playgroundRequest.Group + } + tempToken := &model.Token{ UserId: userId, - Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup), - Group: relayInfo.UsingGroup, + Name: fmt.Sprintf("playground-%s", usingGroup), + Group: usingGroup, } _ = middleware.SetupContextForToken(c, tempToken) - Relay(c, types.RelayFormatOpenAI) + Relay(c, relayFormat) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 2263fae3fae5..f3c70e6ce2a9 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -82,7 +82,7 @@ func Distribute() func(c *gin.Context) { var selectGroup string usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) // check path is /pg/chat/completions - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + if isPlaygroundPath(c.Request.URL.Path) { playgroundRequest := &dto.PlayGroundRequest{} err = common.UnmarshalBodyReusable(c, playgroundRequest) if err != nil { @@ -325,14 +325,16 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } c.Set("relay_mode", relayMode) } - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { - // playground chat completions + if isPlaygroundPath(c.Request.URL.Path) { req, err := getModelFromRequest(c) if err != nil { return nil, false, err } modelRequest.Model = req.Model modelRequest.Group = req.Group + if strings.HasPrefix(c.Request.URL.Path, "/pg/images/generations") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") + } common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) } @@ -342,6 +344,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { return &modelRequest, shouldSelectChannel, nil } +func isPlaygroundPath(path string) bool { + return strings.HasPrefix(path, "/pg/") +} + func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError { c.Set("original_model", modelName) // for retry if channel == nil { diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 256715679213..71b05b7a6a20 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -66,7 +66,7 @@ func Path2RelayMode(path string) int { relayMode = RelayModeEmbeddings } else if strings.HasPrefix(path, "/v1/moderations") { relayMode = RelayModeModerations - } else if strings.HasPrefix(path, "/v1/images/generations") { + } else if strings.HasPrefix(path, "/v1/images/generations") || strings.HasPrefix(path, "/pg/images/generations") { relayMode = RelayModeImagesGenerations } else if strings.HasPrefix(path, "/v1/images/edits") { relayMode = RelayModeImagesEdits @@ -74,7 +74,7 @@ func Path2RelayMode(path string) int { relayMode = RelayModeEdits } else if strings.HasPrefix(path, "/v1/responses/compact") { relayMode = RelayModeResponsesCompact - } else if strings.HasPrefix(path, "/v1/responses") { + } else if strings.HasPrefix(path, "/v1/responses") || strings.HasPrefix(path, "/pg/responses") { relayMode = RelayModeResponses } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index 2581b2812c94..b581318c6511 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "math" + "regexp" + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -138,6 +140,50 @@ func GetAndValidateResponsesCompactionRequest(c *gin.Context) (*dto.OpenAIRespon return request, nil } +var imageSizePattern = regexp.MustCompile(`^([1-9]\d*)[xX]([1-9]\d*)$`) + +var validImageQualities = map[string]struct{}{ + "low": {}, + "medium": {}, + "high": {}, + "auto": {}, + "standard": {}, + "hd": {}, +} + +func validateOpenAIImageRequest(imageRequest *dto.ImageRequest) error { + if imageRequest.Quality != "" { + if _, ok := validImageQualities[imageRequest.Quality]; !ok { + return errors.New("invalid quality, must be one of: low, medium, high, auto, standard, hd") + } + } + + if strings.Contains(imageRequest.Size, "×") { + return errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + } + + if imageRequest.Size != "" { + matches := imageSizePattern.FindStringSubmatch(imageRequest.Size) + if matches == nil { + return errors.New("size must use axb format with positive integer dimensions") + } + + width, err := strconv.Atoi(matches[1]) + if err != nil || width <= 0 { + return errors.New("size must use axb format with positive integer dimensions") + } + height, err := strconv.Atoi(matches[2]) + if err != nil || height <= 0 { + return errors.New("size must use axb format with positive integer dimensions") + } + if width > 3840 || height > 3840 { + return errors.New("size width and height must be between 1 and 3840") + } + } + + return nil +} + func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageRequest, error) { imageRequest := &dto.ImageRequest{} @@ -158,6 +204,10 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq imageRequest.Image, _ = common.Marshal(imageValue) } + if err := validateOpenAIImageRequest(imageRequest); err != nil { + return nil, err + } + if imageRequest.Model == "gpt-image-1" { if imageRequest.Quality == "" { imageRequest.Quality = "standard" @@ -182,12 +232,11 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq } if imageRequest.Model == "" { - //imageRequest.Model = "dall-e-3" return nil, errors.New("model is required") } - if strings.Contains(imageRequest.Size, "×") { - return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + if err := validateOpenAIImageRequest(imageRequest); err != nil { + return nil, err } // Not "256x256", "512x512", or "1024x1024" @@ -214,10 +263,6 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq } } - //if imageRequest.Prompt == "" { - // return nil, errors.New("prompt is required") - //} - if imageRequest.N == nil || *imageRequest.N == 0 { imageRequest.N = common.GetPointer(uint(1)) } diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..d689173c451c 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -10,6 +10,12 @@ import ( "github.com/gin-gonic/gin" ) +func playgroundHandler(relayFormat types.RelayFormat) gin.HandlerFunc { + return func(c *gin.Context) { + controller.Playground(c, relayFormat) + } +} + func SetRelayRouter(router *gin.Engine) { router.Use(middleware.CORS()) router.Use(middleware.DecompressRequestMiddleware()) @@ -64,7 +70,10 @@ func SetRelayRouter(router *gin.Engine) { playgroundRouter.Use(middleware.SystemPerformanceCheck()) playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) { - playgroundRouter.POST("/chat/completions", controller.Playground) + playgroundRouter.POST("/chat/completions", playgroundHandler(types.RelayFormatOpenAI)) + playgroundRouter.POST("/responses", playgroundHandler(types.RelayFormatOpenAIResponses)) + playgroundRouter.POST("/messages", playgroundHandler(types.RelayFormatClaude)) + playgroundRouter.POST("/images/generations", playgroundHandler(types.RelayFormatOpenAIImage)) } relayV1Router := router.Group("/v1") relayV1Router.Use(middleware.RouteTag("relay")) diff --git a/web/classic/src/components/playground/MessageContent.jsx b/web/classic/src/components/playground/MessageContent.jsx index 94f494bb31fb..82f8c48396cc 100644 --- a/web/classic/src/components/playground/MessageContent.jsx +++ b/web/classic/src/components/playground/MessageContent.jsx @@ -42,6 +42,47 @@ const MessageContent = ({ const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete'; + const generatedImages = Array.isArray(message.images) ? message.images : []; + + const getGeneratedImageSrc = (image) => { + if (image.url) { + return image.url; + } + + if (image.b64_json) { + return `data:${image.mime_type || 'image/png'};base64,${image.b64_json}`; + } + + return ''; + }; + + const getTextFromContentPart = (item) => { + if (!item || typeof item !== 'object') return ''; + if (typeof item.text === 'string') return item.text; + if (typeof item.value === 'string') return item.value; + if (typeof item.content === 'string') return item.content; + return ''; + }; + + const getTextContent = (content) => { + if (Array.isArray(content)) { + return content.map(getTextFromContentPart).filter(Boolean).join('\n'); + } else if (typeof content === 'string') { + return content; + } + return ''; + }; + + const getContentImageSrc = (item) => { + if (!item || typeof item !== 'object') return ''; + if (typeof item.image_url === 'string') return item.image_url; + if (typeof item.image_url?.url === 'string') return item.image_url.url; + if (typeof item.url === 'string') return item.url; + if (typeof item.b64_json === 'string') { + return `data:${item.mime_type || 'image/png'};base64,${item.b64_json}`; + } + return ''; + }; useEffect(() => { if (!isThinkingStatus) { @@ -54,11 +95,7 @@ const MessageContent = ({ let errorText; if (Array.isArray(message.content)) { - const textContent = message.content.find((item) => item.type === 'text'); - errorText = - textContent && textContent.text && typeof textContent.text === 'string' - ? textContent.text - : t('请求发生错误'); + errorText = getTextContent(message.content) || t('请求发生错误'); } else if (typeof message.content === 'string') { errorText = message.content; } else { @@ -111,23 +148,9 @@ const MessageContent = ({ } let currentExtractedThinkingContent = null; - let currentDisplayableFinalContent = ''; + let currentDisplayableFinalContent = getTextContent(message.content); let thinkingSource = null; - const getTextContent = (content) => { - if (Array.isArray(content)) { - const textItem = content.find((item) => item.type === 'text'); - return textItem && textItem.text && typeof textItem.text === 'string' - ? textItem.text - : ''; - } else if (typeof content === 'string') { - return content; - } - return ''; - }; - - currentDisplayableFinalContent = getTextContent(message.content); - if (message.role === 'assistant') { let baseContentForDisplay = getTextContent(message.content); let combinedThinkingContent = ''; @@ -296,21 +319,19 @@ const MessageContent = ({ ) : ( (() => { if (Array.isArray(message.content)) { - const textContent = message.content.find( - (item) => item.type === 'text', - ); - const imageContents = message.content.filter( - (item) => item.type === 'image_url', - ); + const displayableTextContent = getTextContent(message.content); + const imageContents = message.content + .map((item) => ({ item, src: getContentImageSrc(item) })) + .filter(({ src }) => src); return (
{imageContents.length > 0 && (
- {imageContents.map((imgItem, index) => ( + {imageContents.map(({ item, src }, index) => (
{`用户上传的图片 - 图片加载失败: {imgItem.image_url.url} + 图片加载失败: {src}
))}
)} - {textContent && - textContent.text && - typeof textContent.text === 'string' && - textContent.text.trim() !== '' && ( -
- -
- )} + {displayableTextContent.trim() !== '' && ( +
+ +
+ )} ); } @@ -405,6 +421,31 @@ const MessageContent = ({ return null; })() )} + + {generatedImages.length > 0 && ( +
+ {generatedImages.map((image, index) => { + const src = getGeneratedImageSrc(image); + + return ( +
+ {src ? ( + {t('生成的图片 + ) : ( +
+ {t('图片数据缺失')} +
+ )} +
+ ); + })} +
+ )} ); }; diff --git a/web/classic/src/components/playground/OptimizedComponents.js b/web/classic/src/components/playground/OptimizedComponents.js index ff679c6591c8..4e87842109f5 100644 --- a/web/classic/src/components/playground/OptimizedComponents.js +++ b/web/classic/src/components/playground/OptimizedComponents.js @@ -35,6 +35,8 @@ export const OptimizedMessageContent = React.memo( prevProps.message.role === nextProps.message.role && prevProps.message.reasoningContent === nextProps.message.reasoningContent && + JSON.stringify(prevProps.message.images) === + JSON.stringify(nextProps.message.images) && prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded && prevProps.isEditing === nextProps.isEditing && diff --git a/web/classic/src/components/playground/SettingsPanel.jsx b/web/classic/src/components/playground/SettingsPanel.jsx index 3899e596fa6e..099c3db16733 100644 --- a/web/classic/src/components/playground/SettingsPanel.jsx +++ b/web/classic/src/components/playground/SettingsPanel.jsx @@ -18,15 +18,44 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui'; +import { Card, Select, Typography, Button, Switch, Input } from '@douyinfe/semi-ui'; import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { renderGroupOption, selectFilter } from '../../helpers'; +import { + getPlaygroundEndpointDescription, + getPlaygroundEndpointLabel, + renderGroupOption, + selectFilter, +} from '../../helpers'; +import { PLAYGROUND_ENDPOINTS } from '../../constants/playground.constants'; +import { + IMAGE_QUALITY_OPTIONS, + validateImageSize, +} from '../../helpers/playgroundValidation'; import ParameterControl from './ParameterControl'; import ImageUrlInput from './ImageUrlInput'; import ConfigManager from './ConfigManager'; import CustomRequestEditor from './CustomRequestEditor'; +const endpointOptions = [ + { + label: getPlaygroundEndpointLabel(PLAYGROUND_ENDPOINTS.CHAT_COMPLETIONS), + value: PLAYGROUND_ENDPOINTS.CHAT_COMPLETIONS, + }, + { + label: getPlaygroundEndpointLabel(PLAYGROUND_ENDPOINTS.RESPONSES), + value: PLAYGROUND_ENDPOINTS.RESPONSES, + }, + { + label: getPlaygroundEndpointLabel(PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES), + value: PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES, + }, + { + label: getPlaygroundEndpointLabel(PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS), + value: PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS, + }, +]; + const SettingsPanel = ({ inputs, parameterEnabled, @@ -45,6 +74,9 @@ const SettingsPanel = ({ onCustomRequestBodyChange, previewPayload, messages, + activeEndpoint, + inferredEndpoint, + onEndpointChange, }) => { const { t } = useTranslation(); @@ -55,6 +87,8 @@ const SettingsPanel = ({ customRequestMode, customRequestBody, }; + const showImageOptions = activeEndpoint === PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS; + const imageSizeError = showImageOptions ? validateImageSize(inputs.image_size) : null; return ( + {/* 端点选择 */} +
+
+ + + {t('Endpoint (auto-detected)')} + + {customRequestMode && ( + + ({t('已在自定义模式中忽略')}) + + )} +
+ onInputChange('image_quality', value)} + value={inputs.image_quality} + autoComplete='new-password' + optionList={IMAGE_QUALITY_OPTIONS.map((quality) => ({ + label: t(quality), + value: quality, + }))} + style={{ width: '100%' }} + dropdownStyle={{ width: '100%', maxWidth: '100%' }} + className='!rounded-lg' + disabled={customRequestMode} + /> +
+ +
+
+ + + {t('Image size')} + + {customRequestMode && ( + + ({t('已在自定义模式中忽略')}) + + )} +
+ onInputChange('image_size', value)} + placeholder='1024x1024' + disabled={customRequestMode} + className='!rounded-lg' + style={{ width: '100%' }} + /> + {imageSizeError && ( + + {t(imageSizeError)} + + )} +
+ + )} + {/* 图片URL输入 */}
[ { @@ -76,6 +83,9 @@ export const DEBUG_TABS = { // ========== API 相关常量 ========== export const API_ENDPOINTS = { CHAT_COMPLETIONS: '/pg/chat/completions', + RESPONSES: '/pg/responses', + CLAUDE_MESSAGES: '/pg/messages', + IMAGE_GENERATIONS: '/pg/images/generations', USER_MODELS: '/api/user/models', USER_GROUPS: '/api/user/self/groups', }; @@ -85,15 +95,21 @@ export const DEFAULT_CONFIG = { inputs: { model: 'gpt-4o', group: '', + endpointOverride: null, temperature: 0.7, top_p: 1, max_tokens: 4096, + max_output_tokens: 4096, frequency_penalty: 0, presence_penalty: 0, seed: null, stream: true, imageEnabled: false, imageUrls: [''], + image_size: '1024x1024', + image_quality: 'high', + image_n: 1, + image_response_format: 'url', }, parameterEnabled: { temperature: true, diff --git a/web/classic/src/helpers/api.js b/web/classic/src/helpers/api.js index 88122a564cff..1d0c5da9344f 100644 --- a/web/classic/src/helpers/api.js +++ b/web/classic/src/helpers/api.js @@ -22,9 +22,13 @@ import { showError, formatMessageForAPI, isValidMessage, + getLastUserMessage, } from './utils'; import axios from 'axios'; -import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { + MESSAGE_ROLES, + PLAYGROUND_ENDPOINTS, +} from '../constants/playground.constants'; export let API = axios.create({ baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL @@ -108,19 +112,116 @@ API.interceptors.response.use( // playground -// 构建API请求负载 -export const buildApiPayload = ( - messages, - systemPrompt, - inputs, - parameterEnabled, -) => { - const processedMessages = messages - .filter(isValidMessage) - .map(formatMessageForAPI) - .filter(Boolean); +export const inferPlaygroundEndpoint = (model = '') => { + const normalized = String(model).toLowerCase(); + + if ( + normalized.includes('gpt-image') || + normalized.includes('dall-e') || + normalized.includes('imagen-') || + normalized.includes('flux-') || + normalized.includes('flux.1-') + ) { + return PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS; + } + + if ( + normalized.includes('claude') || + normalized.includes('haiku') || + normalized.includes('sonnet') || + normalized.includes('opus') + ) { + return PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES; + } + + if ( + normalized.startsWith('gpt-') || + normalized.startsWith('chatgpt') || + /^o\d/.test(normalized) + ) { + return PLAYGROUND_ENDPOINTS.RESPONSES; + } + + return PLAYGROUND_ENDPOINTS.CHAT_COMPLETIONS; +}; + +export const getPlaygroundEndpointLabel = (endpoint) => { + switch (endpoint) { + case PLAYGROUND_ENDPOINTS.RESPONSES: + return 'Responses (/v1/responses)'; + case PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES: + return 'Claude Messages (/v1/messages)'; + case PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS: + return 'Images (/v1/images/generations)'; + default: + return 'Chat Completions (/v1/chat/completions)'; + } +}; + +export const getPlaygroundEndpointDescription = (endpoint) => { + switch (endpoint) { + case PLAYGROUND_ENDPOINTS.RESPONSES: + return 'GPT text models, including Responses image_generation_call results.'; + case PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES: + return 'Claude Haiku, Sonnet, and Opus models.'; + case PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS: + return 'Dedicated image models such as gpt-image and dall-e.'; + default: + return 'Legacy OpenAI-compatible chat completion models.'; + } +}; + +export const getPlaygroundEndpointUrl = (endpoint) => { + switch (endpoint) { + case PLAYGROUND_ENDPOINTS.RESPONSES: + return '/pg/responses'; + case PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES: + return '/pg/messages'; + case PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS: + return '/pg/images/generations'; + default: + return '/pg/chat/completions'; + } +}; + +const extractTextFromMessageContent = (content) => { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + + return content + .map((part) => { + if (!part || typeof part !== 'object') return ''; + if (part.type === 'text' && typeof part.text === 'string') { + return part.text; + } + return ''; + }) + .filter(Boolean) + .join('\n'); +}; + +const getProcessedMessages = (messages) => + messages.filter(isValidMessage).map(formatMessageForAPI).filter(Boolean); + +const getLastUserPrompt = (messages) => { + const lastUserMessage = getLastUserMessage(messages); + return lastUserMessage ? extractTextFromMessageContent(lastUserMessage.content) : ''; +}; + +const applyCommonTextParameters = (payload, inputs, parameterEnabled, maxTokenKey) => { + if (parameterEnabled.temperature) payload.temperature = inputs.temperature; + if (parameterEnabled.top_p) payload.top_p = inputs.top_p; + if (parameterEnabled.max_tokens) { + payload[maxTokenKey] = + maxTokenKey === 'max_output_tokens' + ? inputs.max_output_tokens + : inputs.max_tokens; + } +}; + +const buildChatCompletionPayload = (messages, systemPrompt, inputs, parameterEnabled) => { + const processedMessages = getProcessedMessages(messages); - // 如果有系统提示,插入到消息开头 if (systemPrompt && systemPrompt.trim()) { processedMessages.unshift({ role: MESSAGE_ROLES.SYSTEM, @@ -135,7 +236,6 @@ export const buildApiPayload = ( stream: inputs.stream, }; - // 添加启用的参数 const parameterMappings = { temperature: 'temperature', top_p: 'top_p', @@ -150,25 +250,106 @@ export const buildApiPayload = ( const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (!enabled) { - return; - } + if (!enabled) return; if (param === 'max_tokens') { - if (typeof value === 'number') { - payload[param] = value; - } + if (typeof value === 'number') payload[param] = value; return; } - if (hasValue) { - payload[param] = value; - } + if (hasValue) payload[param] = value; }); return payload; }; +const buildResponsesPayload = (messages, systemPrompt, inputs, parameterEnabled) => { + const processedMessages = getProcessedMessages(messages); + const systemMessages = processedMessages.filter((m) => m.role === MESSAGE_ROLES.SYSTEM); + const input = processedMessages.filter((m) => m.role !== MESSAGE_ROLES.SYSTEM); + const payload = { + model: inputs.model, + group: inputs.group, + input, + stream: inputs.stream, + }; + + const instructionParts = []; + if (systemPrompt && systemPrompt.trim()) { + instructionParts.push(systemPrompt.trim()); + } + instructionParts.push( + ...systemMessages + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter(Boolean), + ); + if (instructionParts.length > 0) { + payload.instructions = instructionParts.join('\n\n'); + } + + applyCommonTextParameters(payload, inputs, parameterEnabled, 'max_output_tokens'); + return payload; +}; + +const buildClaudeMessagesPayload = (messages, systemPrompt, inputs, parameterEnabled) => { + const processedMessages = getProcessedMessages(messages); + const systemMessages = processedMessages.filter((m) => m.role === MESSAGE_ROLES.SYSTEM); + const payload = { + model: inputs.model, + group: inputs.group, + messages: processedMessages.filter((m) => m.role !== MESSAGE_ROLES.SYSTEM), + stream: inputs.stream, + max_tokens: inputs.max_tokens, + }; + + const systemParts = []; + if (systemPrompt && systemPrompt.trim()) { + systemParts.push(systemPrompt.trim()); + } + systemParts.push( + ...systemMessages + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter(Boolean), + ); + if (systemParts.length > 0) { + payload.system = systemParts.join('\n\n'); + } + + applyCommonTextParameters(payload, inputs, parameterEnabled, 'max_tokens'); + payload.max_tokens = inputs.max_tokens; + return payload; +}; + +const buildImageGenerationPayload = (messages, inputs) => ({ + model: inputs.model, + group: inputs.group, + prompt: getLastUserPrompt(messages), + n: inputs.image_n, + size: inputs.image_size, + quality: inputs.image_quality, + response_format: inputs.image_response_format, +}); + +// 构建API请求负载 +export const buildApiPayload = ( + messages, + systemPrompt, + inputs, + parameterEnabled, + endpoint = inferPlaygroundEndpoint(inputs.model), +) => { + switch (endpoint) { + case PLAYGROUND_ENDPOINTS.RESPONSES: + return buildResponsesPayload(messages, systemPrompt, inputs, parameterEnabled); + case PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES: + return buildClaudeMessagesPayload(messages, systemPrompt, inputs, parameterEnabled); + case PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS: + return buildImageGenerationPayload(messages, inputs); + default: + return buildChatCompletionPayload(messages, systemPrompt, inputs, parameterEnabled); + } +}; + // 处理API错误响应 export const handleApiError = (error, response = null) => { const errorInfo = { diff --git a/web/classic/src/helpers/playgroundValidation.js b/web/classic/src/helpers/playgroundValidation.js new file mode 100644 index 000000000000..f616238df5ad --- /dev/null +++ b/web/classic/src/helpers/playgroundValidation.js @@ -0,0 +1,21 @@ +import { PLAYGROUND_ENDPOINTS } from '../constants/playground.constants'; + +export const IMAGE_QUALITY_OPTIONS = ['low', 'medium', 'high', 'auto']; + +export const validateImageSize = (size = '') => { + const trimmedSize = String(size).trim(); + const match = /^([1-9]\d*)[xX]([1-9]\d*)$/.exec(trimmedSize); + + if (!match) return 'Size must use axb format with positive integer dimensions'; + + const width = Number(match[1]); + const height = Number(match[2]); + if (width > 3840 || height > 3840) { + return 'Size width and height must be less than or equal to 3840'; + } + + return null; +}; + +export const isImageGenerationEndpoint = (endpoint) => + endpoint === PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS; diff --git a/web/classic/src/hooks/playground/useApiRequest.jsx b/web/classic/src/hooks/playground/useApiRequest.jsx index f072084b63ae..aab23a27d0ae 100644 --- a/web/classic/src/hooks/playground/useApiRequest.jsx +++ b/web/classic/src/hooks/playground/useApiRequest.jsx @@ -21,17 +21,116 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { SSE } from 'sse.js'; import { - API_ENDPOINTS, MESSAGE_STATUS, DEBUG_TABS, + PLAYGROUND_ENDPOINTS, } from '../../constants/playground.constants'; import { getUserIdFromLocalStorage, + getPlaygroundEndpointUrl, handleApiError, processThinkTags, processIncompleteThinkTags, } from '../../helpers'; +const extractTextFromContent = (content) => { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + + return content + .map((part) => { + if (!part || typeof part !== 'object') return ''; + if (typeof part.text === 'string') return part.text; + if (typeof part.content === 'string') return part.content; + if (part.type === 'text' && typeof part.value === 'string') return part.value; + return ''; + }) + .filter(Boolean) + .join('\n'); +}; + +const extractImages = (value) => { + const images = []; + + const visit = (node) => { + if (!node || typeof node !== 'object') return; + + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + + const maybeImage = {}; + if (typeof node.url === 'string') maybeImage.url = node.url; + if (typeof node.image_url === 'string') maybeImage.url = node.image_url; + if (typeof node.b64_json === 'string') maybeImage.b64_json = node.b64_json; + if (typeof node.result === 'string') maybeImage.b64_json = node.result; + if (typeof node.mime_type === 'string') maybeImage.mime_type = node.mime_type; + + if (maybeImage.url || maybeImage.b64_json) { + images.push(maybeImage); + } + + Object.values(node).forEach(visit); + }; + + visit(value); + return images; +}; + +const normalizePlaygroundResponse = (endpoint, response) => { + if (endpoint === PLAYGROUND_ENDPOINTS.RESPONSES) { + const images = extractImages(response?.output); + if (typeof response?.output_text === 'string' && response.output_text) { + return { content: response.output_text, images }; + } + + const output = Array.isArray(response?.output) ? response.output : []; + const content = output + .map((item) => extractTextFromContent(item?.content)) + .filter(Boolean) + .join('\n'); + return { content, images }; + } + + if (endpoint === PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES) { + const content = extractTextFromContent(response?.content); + const reasoning = Array.isArray(response?.content) + ? response.content + .map((part) => { + if (!part || typeof part !== 'object') return ''; + if ( + (part.type === 'thinking' || part.type === 'reasoning') && + typeof part.thinking === 'string' + ) { + return part.thinking; + } + if ( + (part.type === 'thinking' || part.type === 'reasoning') && + typeof part.text === 'string' + ) { + return part.text; + } + return ''; + }) + .filter(Boolean) + .join('\n') + : ''; + return { content, reasoning }; + } + + if (endpoint === PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS) { + return { content: '', images: extractImages(response?.data) }; + } + + const choice = response?.choices?.[0]; + return { + content: choice?.message?.content || '', + reasoning: + choice?.message?.reasoning_content || choice?.message?.reasoning || '', + }; +}; + export const useApiRequest = ( setMessage, setDebugData, @@ -41,32 +140,61 @@ export const useApiRequest = ( ) => { const { t } = useTranslation(); - // 处理消息自动关闭逻辑的公共函数 - const applyAutoCollapseLogic = useCallback( - (message, isThinkingComplete = true) => { - const shouldAutoCollapse = - isThinkingComplete && !message.hasAutoCollapsed; - return { - isThinkingComplete, - hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed, - isReasoningExpanded: shouldAutoCollapse - ? false - : message.isReasoningExpanded, - }; + const applyAutoCollapseLogic = useCallback((message, isThinkingComplete = true) => { + const shouldAutoCollapse = isThinkingComplete && !message.hasAutoCollapsed; + return { + isThinkingComplete, + hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed, + isReasoningExpanded: shouldAutoCollapse ? false : message.isReasoningExpanded, + }; + }, []); + + const updateLastAssistantWithResult = useCallback( + (result, status = MESSAGE_STATUS.COMPLETE) => { + setMessage((prevMessage) => { + const lastMessage = prevMessage[prevMessage.length - 1]; + if (!lastMessage || lastMessage.role !== 'assistant') return prevMessage; + if ( + lastMessage.status === MESSAGE_STATUS.COMPLETE || + lastMessage.status === MESSAGE_STATUS.ERROR + ) { + return prevMessage; + } + + const processed = processThinkTags( + result?.content ?? lastMessage.content ?? '', + result?.reasoning ?? lastMessage.reasoningContent ?? '', + ); + const autoCollapseState = applyAutoCollapseLogic(lastMessage, true); + const updatedMessages = [ + ...prevMessage.slice(0, -1), + { + ...lastMessage, + content: processed.content, + reasoningContent: processed.reasoningContent, + images: result?.images?.length ? result.images : lastMessage.images, + status, + ...autoCollapseState, + }, + ]; + + if (status === MESSAGE_STATUS.COMPLETE || status === MESSAGE_STATUS.ERROR) { + setTimeout(() => saveMessages(updatedMessages), 0); + } + + return updatedMessages; + }); }, - [], + [setMessage, applyAutoCollapseLogic, saveMessages], ); - // 流式消息更新 const streamMessageUpdate = useCallback( (textChunk, type) => { setMessage((prevMessage) => { const lastMessage = prevMessage[prevMessage.length - 1]; if (!lastMessage) return prevMessage; if (lastMessage.role !== 'assistant') return prevMessage; - if (lastMessage.status === MESSAGE_STATUS.ERROR) { - return prevMessage; - } + if (lastMessage.status === MESSAGE_STATUS.ERROR) return prevMessage; if ( lastMessage.status === MESSAGE_STATUS.LOADING || @@ -77,23 +205,15 @@ export const useApiRequest = ( if (type === 'reasoning') { newMessage = { ...newMessage, - reasoningContent: - (lastMessage.reasoningContent || '') + textChunk, + reasoningContent: (lastMessage.reasoningContent || '') + textChunk, status: MESSAGE_STATUS.INCOMPLETE, isThinkingComplete: false, }; } else if (type === 'content') { - const shouldCollapseReasoning = - !lastMessage.content && lastMessage.reasoningContent; const newContent = (lastMessage.content || '') + textChunk; - - let shouldCollapseFromThinkTag = false; let thinkingCompleteFromTags = lastMessage.isThinkingComplete; - if ( - lastMessage.isReasoningExpanded && - newContent.includes('') - ) { + if (lastMessage.isReasoningExpanded && newContent.includes('')) { const thinkMatches = newContent.match(//g); const thinkCloseMatches = newContent.match(/<\/think>/g); if ( @@ -101,17 +221,13 @@ export const useApiRequest = ( thinkCloseMatches && thinkCloseMatches.length >= thinkMatches.length ) { - shouldCollapseFromThinkTag = true; - thinkingCompleteFromTags = true; // think标签闭合也标记思考完成 + thinkingCompleteFromTags = true; } } - // 如果开始接收content内容,且之前有reasoning内容,或者think标签已闭合,则标记思考完成 const isThinkingComplete = - (lastMessage.reasoningContent && - !lastMessage.isThinkingComplete) || + (lastMessage.reasoningContent && !lastMessage.isThinkingComplete) || thinkingCompleteFromTags; - const autoCollapseState = applyAutoCollapseLogic( lastMessage, isThinkingComplete, @@ -134,7 +250,6 @@ export const useApiRequest = ( [setMessage, applyAutoCollapseLogic], ); - // 完成消息 const completeMessage = useCallback( (status = MESSAGE_STATUS.COMPLETE) => { setMessage((prevMessage) => { @@ -147,45 +262,39 @@ export const useApiRequest = ( } const autoCollapseState = applyAutoCollapseLogic(lastMessage, true); - const updatedMessages = [ ...prevMessage.slice(0, -1), { ...lastMessage, - status: status, + status, ...autoCollapseState, }, ]; - // 在消息完成时保存,传入更新后的消息列表 - if ( - status === MESSAGE_STATUS.COMPLETE || - status === MESSAGE_STATUS.ERROR - ) { + if (status === MESSAGE_STATUS.COMPLETE || status === MESSAGE_STATUS.ERROR) { setTimeout(() => saveMessages(updatedMessages), 0); } return updatedMessages; }); }, - [setMessage, applyAutoCollapseLogic, saveMessages], + [setMessage, applyAutoCollapseLogic, saveMessages, t], ); - // 非流式请求 const handleNonStreamRequest = useCallback( - async (payload) => { + async (payload, endpoint) => { setDebugData((prev) => ({ ...prev, request: payload, timestamp: new Date().toISOString(), response: null, - sseMessages: null, // 非流式请求清除 SSE 消息 + sseMessages: null, isStreaming: false, })); setActiveDebugTab(DEBUG_TABS.REQUEST); try { - const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, { + const response = await fetch(getPlaygroundEndpointUrl(endpoint), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -200,19 +309,13 @@ export const useApiRequest = ( try { errorBody = await response.text(); const errorJson = JSON.parse(errorBody); - if (errorJson?.error) { - parsedError = errorJson.error; - } + if (errorJson?.error) parsedError = errorJson.error; } catch (e) { - if (!errorBody) { - errorBody = '无法读取错误响应体'; - } + if (!errorBody) errorBody = '无法读取错误响应体'; } const errorInfo = handleApiError( - new Error( - `HTTP error! status: ${response.status}, body: ${errorBody}`, - ), + new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`), response, ); @@ -232,43 +335,13 @@ export const useApiRequest = ( } const data = await response.json(); - setDebugData((prev) => ({ ...prev, response: JSON.stringify(data, null, 2), })); setActiveDebugTab(DEBUG_TABS.RESPONSE); - if (data.choices?.[0]) { - const choice = data.choices[0]; - let content = choice.message?.content || ''; - let reasoningContent = - choice.message?.reasoning_content || - choice.message?.reasoning || - ''; - - const processed = processThinkTags(content, reasoningContent); - - setMessage((prevMessage) => { - const newMessages = [...prevMessage]; - const lastMessage = newMessages[newMessages.length - 1]; - if (lastMessage?.status === MESSAGE_STATUS.LOADING) { - const autoCollapseState = applyAutoCollapseLogic( - lastMessage, - true, - ); - - newMessages[newMessages.length - 1] = { - ...lastMessage, - content: processed.content, - reasoningContent: processed.reasoningContent, - status: MESSAGE_STATUS.COMPLETE, - ...autoCollapseState, - }; - } - return newMessages; - }); - } + updateLastAssistantWithResult(normalizePlaygroundResponse(endpoint, data)); } catch (error) { console.error('Non-stream request error:', error); @@ -297,23 +370,29 @@ export const useApiRequest = ( }); } }, - [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic], + [ + setDebugData, + setActiveDebugTab, + setMessage, + t, + applyAutoCollapseLogic, + updateLastAssistantWithResult, + ], ); - // SSE请求 const handleSSE = useCallback( - (payload) => { + (payload, endpoint) => { setDebugData((prev) => ({ ...prev, request: payload, timestamp: new Date().toISOString(), response: null, - sseMessages: [], // 新增:存储 SSE 消息数组 - isStreaming: true, // 新增:标记流式状态 + sseMessages: [], + isStreaming: true, })); setActiveDebugTab(DEBUG_TABS.REQUEST); - const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { + const source = new SSE(getPlaygroundEndpointUrl(endpoint), { headers: { 'Content-Type': 'application/json', 'New-Api-User': getUserIdFromLocalStorage(), @@ -326,17 +405,17 @@ export const useApiRequest = ( let responseData = ''; let hasReceivedFirstResponse = false; - let isStreamComplete = false; // 添加标志位跟踪流是否正常完成 + let isStreamComplete = false; - source.addEventListener('message', (e) => { - if (e.data === '[DONE]') { - isStreamComplete = true; // 标记流正常完成 + const handleStreamData = (data, eventType) => { + if (data === '[DONE]') { + isStreamComplete = true; source.close(); sseSourceRef.current = null; setDebugData((prev) => ({ ...prev, response: responseData, - sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记 + sseMessages: [...(prev.sseMessages || []), '[DONE]'], isStreaming: false, })); completeMessage(); @@ -344,31 +423,76 @@ export const useApiRequest = ( } try { - const payload = JSON.parse(e.data); - responseData += e.data + '\n'; + const parsed = JSON.parse(data); + responseData += data + '\n'; if (!hasReceivedFirstResponse) { setActiveDebugTab(DEBUG_TABS.RESPONSE); hasReceivedFirstResponse = true; } - // 新增:将 SSE 消息添加到数组 setDebugData((prev) => ({ ...prev, - sseMessages: [...(prev.sseMessages || []), e.data], + sseMessages: [...(prev.sseMessages || []), data], })); - const delta = payload.choices?.[0]?.delta; - if (delta) { - if (delta.reasoning_content) { - streamMessageUpdate(delta.reasoning_content, 'reasoning'); + if (endpoint === PLAYGROUND_ENDPOINTS.RESPONSES) { + const type = parsed.type || eventType; + if ( + (type === 'response.output_text.delta' || + type === 'response.reasoning_text.delta') && + typeof parsed.delta === 'string' + ) { + streamMessageUpdate( + parsed.delta, + type === 'response.reasoning_text.delta' ? 'reasoning' : 'content', + ); + } + if (type === 'response.completed' && parsed.response) { + isStreamComplete = true; + source.close(); + sseSourceRef.current = null; + setDebugData((prev) => ({ + ...prev, + response: responseData, + isStreaming: false, + })); + updateLastAssistantWithResult( + normalizePlaygroundResponse(PLAYGROUND_ENDPOINTS.RESPONSES, parsed.response), + ); } - if (delta.reasoning) { - streamMessageUpdate(delta.reasoning, 'reasoning'); + return; + } + + if (endpoint === PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES) { + const type = parsed.type || eventType; + if (type === 'content_block_delta' && parsed.delta) { + if (typeof parsed.delta.thinking === 'string') { + streamMessageUpdate(parsed.delta.thinking, 'reasoning'); + } + if (typeof parsed.delta.text === 'string') { + streamMessageUpdate(parsed.delta.text, 'content'); + } } - if (delta.content) { - streamMessageUpdate(delta.content, 'content'); + if (type === 'message_stop') { + isStreamComplete = true; + source.close(); + sseSourceRef.current = null; + setDebugData((prev) => ({ + ...prev, + response: responseData, + isStreaming: false, + })); + completeMessage(); } + return; + } + + const delta = parsed.choices?.[0]?.delta; + if (delta) { + if (delta.reasoning_content) streamMessageUpdate(delta.reasoning_content, 'reasoning'); + if (delta.reasoning) streamMessageUpdate(delta.reasoning, 'reasoning'); + if (delta.content) streamMessageUpdate(delta.content, 'content'); } } catch (error) { console.error('Failed to parse SSE message:', error); @@ -377,7 +501,7 @@ export const useApiRequest = ( setDebugData((prev) => ({ ...prev, response: responseData + `\n\nError: ${errorInfo}`, - sseMessages: [...(prev.sseMessages || []), e.data], // 即使解析失败也保存原始数据 + sseMessages: [...(prev.sseMessages || []), data], isStreaming: false, })); setActiveDebugTab(DEBUG_TABS.RESPONSE); @@ -385,10 +509,25 @@ export const useApiRequest = ( streamMessageUpdate(t('解析响应数据时发生错误'), 'content'); completeMessage(MESSAGE_STATUS.ERROR); } - }); + }; + + const addDataListener = (eventName) => { + source.addEventListener(eventName, (e) => handleStreamData(e.data, eventName)); + }; + + if (endpoint === PLAYGROUND_ENDPOINTS.RESPONSES) { + [ + 'response.output_text.delta', + 'response.reasoning_text.delta', + 'response.completed', + ].forEach(addDataListener); + } else if (endpoint === PLAYGROUND_ENDPOINTS.CLAUDE_MESSAGES) { + ['content_block_delta', 'message_stop'].forEach(addDataListener); + } else { + addDataListener('message'); + } source.addEventListener('error', (e) => { - // 只有在流没有正常完成且连接状态异常时才处理错误 if (!isStreamComplete && source.readyState !== 2) { console.error('SSE Error:', e); let errorMessage = e.data || t('请求发生错误'); @@ -401,9 +540,7 @@ export const useApiRequest = ( errorMessage = errorJson.error.message || errorMessage; errorCode = errorJson.error.code || null; } - } catch (_) { - // not JSON, use raw data as error message - } + } catch (_) {} } const errorInfo = handleApiError(new Error(errorMessage)); @@ -411,21 +548,22 @@ export const useApiRequest = ( setDebugData((prev) => ({ ...prev, - response: - responseData + - '\n\nSSE Error:\n' + - JSON.stringify(errorInfo, null, 2), + response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2), })); setActiveDebugTab(DEBUG_TABS.RESPONSE); setMessage((prevMessage) => { const newMessages = [...prevMessage]; const lastMessage = newMessages[newMessages.length - 1]; - if (lastMessage && lastMessage.status !== MESSAGE_STATUS.COMPLETE && lastMessage.status !== MESSAGE_STATUS.ERROR) { + if ( + lastMessage && + lastMessage.status !== MESSAGE_STATUS.COMPLETE && + lastMessage.status !== MESSAGE_STATUS.ERROR + ) { newMessages[newMessages.length - 1] = { ...lastMessage, content: (lastMessage.content || '') + errorMessage, - errorCode: errorCode, + errorCode, status: MESSAGE_STATUS.ERROR, }; } @@ -437,7 +575,6 @@ export const useApiRequest = ( }); source.addEventListener('readystatechange', (e) => { - // 检查 HTTP 状态错误,但避免与正常关闭重复处理 if ( e.readyState >= 2 && source.status !== undefined && @@ -450,10 +587,7 @@ export const useApiRequest = ( setDebugData((prev) => ({ ...prev, - response: - responseData + - '\n\nHTTP Error:\n' + - JSON.stringify(errorInfo, null, 2), + response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2), })); setActiveDebugTab(DEBUG_TABS.RESPONSE); @@ -485,20 +619,18 @@ export const useApiRequest = ( setMessage, streamMessageUpdate, completeMessage, + updateLastAssistantWithResult, t, - applyAutoCollapseLogic, + sseSourceRef, ], ); - // 停止生成 const onStopGenerator = useCallback(() => { - // 如果仍有活动的 SSE 连接,首先关闭 if (sseSourceRef.current) { sseSourceRef.current.close(); sseSourceRef.current = null; } - // 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息 setMessage((prevMessage) => { if (prevMessage.length === 0) return prevMessage; const lastMessage = prevMessage[prevMessage.length - 1]; @@ -513,7 +645,6 @@ export const useApiRequest = ( ); const autoCollapseState = applyAutoCollapseLogic(lastMessage, true); - const updatedMessages = [ ...prevMessage.slice(0, -1), { @@ -525,22 +656,19 @@ export const useApiRequest = ( }, ]; - // 停止生成时也保存,传入更新后的消息列表 setTimeout(() => saveMessages(updatedMessages), 0); - return updatedMessages; } return prevMessage; }); - }, [setMessage, applyAutoCollapseLogic, saveMessages]); + }, [setMessage, applyAutoCollapseLogic, saveMessages, sseSourceRef]); - // 发送请求 const sendRequest = useCallback( - (payload, isStream) => { - if (isStream) { - handleSSE(payload); + (payload, isStream, endpoint = PLAYGROUND_ENDPOINTS.CHAT_COMPLETIONS) => { + if (isStream && endpoint !== PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS) { + handleSSE(payload, endpoint); } else { - handleNonStreamRequest(payload); + handleNonStreamRequest(payload, endpoint); } }, [handleSSE, handleNonStreamRequest], diff --git a/web/classic/src/hooks/playground/useMessageEdit.jsx b/web/classic/src/hooks/playground/useMessageEdit.jsx index b0429188afb8..b914fc5f5c97 100644 --- a/web/classic/src/hooks/playground/useMessageEdit.jsx +++ b/web/classic/src/hooks/playground/useMessageEdit.jsx @@ -25,7 +25,10 @@ import { buildApiPayload, createLoadingAssistantMessage, } from '../../helpers'; -import { MESSAGE_ROLES } from '../../constants/playground.constants'; +import { + MESSAGE_ROLES, + PLAYGROUND_ENDPOINTS, +} from '../../constants/playground.constants'; export const useMessageEdit = ( setMessage, @@ -33,6 +36,7 @@ export const useMessageEdit = ( parameterEnabled, sendRequest, saveMessages, + endpoint, ) => { const { t } = useTranslation(); const [editingMessageId, setEditingMessageId] = useState(null); @@ -102,12 +106,17 @@ export const useMessageEdit = ( null, inputs, parameterEnabled, + endpoint, ); setMessage((prevMsg) => [ ...prevMsg, createLoadingAssistantMessage(), ]); - sendRequest(payload, inputs.stream); + sendRequest( + payload, + inputs.stream && endpoint !== PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS, + endpoint, + ); }, 100); }, onCancel: () => { diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 829a9e4b2a38..6a3ff8dc98e6 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -2599,7 +2599,9 @@ "站点额度展示类型及汇率": "Site quota display type and exchange rate", "端口号必须在1-65535之间": "Port number must be between 1-65535", "端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.", - "端点": "Endpoint", + "端点(自动识别)": "Endpoint (auto-detected)", + "生成的图片 {{index}}": "Generated image {{index}}", + "图片数据缺失": "Image data missing", "端点 URL 必须以 http:// 或 https:// 开头:": "Endpoint URL must start with http:// or https://: ", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "Endpoint URL must be a full address (starting with http:// or https://)", "端点映射": "Endpoint mapping", @@ -3794,6 +3796,18 @@ "见上方动态计费详情": "See dynamic pricing details above", "含时间条件": "Time rules", "含请求条件": "Request rules", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)" + "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)", + "Endpoint (auto-detected)": "Endpoint (auto-detected)", + "Auto-detected": "Auto-detected", + "Image quality": "Image quality", + "Image size": "Image size", + "Quality": "Quality", + "low": "low", + "medium": "medium", + "high": "high", + "auto": "auto", + "Size must use axb format with positive integer dimensions": "Size must use axb format with positive integer dimensions", + "Size width and height must be less than or equal to 3840": "Size width and height must be less than or equal to 3840", + "请选择质量": "Please select quality" } } diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index e433dc8f72ea..5efd4b357aee 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -2587,7 +2587,9 @@ "站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change", "端口号必须在1-65535之间": "Port number must be between 1-65535", "端口配置详细说明": "Limitez les requêtes externes à des ports spécifiques. Utilisez des ports uniques (80, 443) ou des plages (8000-8999). Une liste vide autorise tous les ports. La valeur par défaut inclut les ports Web courants.", - "端点": "Point de terminaison", + "端点(自动识别)": "Point de terminaison (détection automatique)", + "生成的图片 {{index}}": "Image générée {{index}}", + "图片数据缺失": "Données d’image manquantes", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "L'URL du point de terminaison doit être une adresse complète (commençant par http:// ou https://)", "端点映射": "Mappage de points de terminaison", "端点类型": "Type de point de terminaison", @@ -3649,6 +3651,18 @@ "默认用户消息": "Bonjour", "默认补全倍率": "Taux de complétion par défaut", "阶梯计费(表达式解析失败)": "Facturation par paliers (échec de l'analyse de l'expression)", - "阶梯计费(未匹配到对应阶梯)": "Facturation par paliers (aucun palier correspondant)" + "阶梯计费(未匹配到对应阶梯)": "Facturation par paliers (aucun palier correspondant)", + "Endpoint (auto-detected)": "Point de terminaison (détection automatique)", + "Auto-detected": "Détection automatique", + "Image quality": "Qualité de l’image", + "Image size": "Taille de l’image", + "Quality": "Qualité", + "low": "faible", + "medium": "moyenne", + "high": "élevée", + "auto": "auto", + "Size must use axb format with positive integer dimensions": "La taille doit utiliser le format axb avec des dimensions entières positives", + "Size width and height must be less than or equal to 3840": "La largeur et la hauteur doivent être inférieures ou égales à 3840", + "请选择质量": "Veuillez choisir la qualité" } } diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index 5cfa0a2615f2..a3add223e501 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -2556,7 +2556,9 @@ "站点额度展示类型及汇率": "サイトの残高表示タイプと為替レート", "端口号必须在1-65535之间": "Port number must be between 1-65535", "端口配置详细说明": "ポート設定の詳細説明", - "端点": "エンドポイント", + "端点(自动识别)": "エンドポイント(自動判定)", + "生成的图片 {{index}}": "生成された画像 {{index}}", + "图片数据缺失": "画像データがありません", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "エンドポイントURLは完全なアドレスである必要があります(http://またはhttps://で始まる)", "端点映射": "エンドポイントマッピング", "端点类型": "エンドポイントタイプ", @@ -3618,6 +3620,18 @@ "默认用户消息": "こんにちは", "默认补全倍率": "デフォルト補完倍率", "阶梯计费(表达式解析失败)": "段階課金(式の解析に失敗)", - "阶梯计费(未匹配到对应阶梯)": "段階課金(一致する階層なし)" + "阶梯计费(未匹配到对应阶梯)": "段階課金(一致する階層なし)", + "Endpoint (auto-detected)": "エンドポイント(自動判定)", + "Auto-detected": "自動判定", + "Image quality": "画像品質", + "Image size": "画像サイズ", + "Quality": "品質", + "low": "低", + "medium": "中", + "high": "高", + "auto": "自動", + "Size must use axb format with positive integer dimensions": "サイズは正の整数の axb 形式で入力してください", + "Size width and height must be less than or equal to 3840": "サイズの幅と高さはどちらも 3840 以下である必要があります", + "请选择质量": "品質を選択してください" } } diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index f4950bfeed8e..538ad67f8b30 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -2607,7 +2607,9 @@ "站点额度展示类型及汇率": "Тип отображения квот сайта и обменные курсы", "端口号必须在1-65535之间": "Port number must be between 1-65535", "端口配置详细说明": "Ограничение внешних запросов только к указанным портам. Поддерживает отдельные порты (80, 443) или диапазоны портов (8000-8999). Пустой список разрешает все порты. По умолчанию включает распространенные веб-порты.", - "端点": "Конечная точка", + "端点(自动识别)": "Конечная точка (автоопределение)", + "生成的图片 {{index}}": "Созданное изображение {{index}}", + "图片数据缺失": "Данные изображения отсутствуют", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "URL конечной точки должен быть полным адресом (начинающимся с http:// или https://)", "端点映射": "Отображение конечных точек", "端点类型": "Тип конечной точки", @@ -3669,6 +3671,18 @@ "默认用户消息": "Здравствуйте", "默认补全倍率": "Коэффициент завершения по умолчанию", "阶梯计费(表达式解析失败)": "Многоуровневая тарификация (ошибка разбора выражения)", - "阶梯计费(未匹配到对应阶梯)": "Многоуровневая тарификация (подходящий уровень не найден)" + "阶梯计费(未匹配到对应阶梯)": "Многоуровневая тарификация (подходящий уровень не найден)", + "Endpoint (auto-detected)": "Эндпоинт (автоопределение)", + "Auto-detected": "Автоопределение", + "Image quality": "Качество изображения", + "Image size": "Размер изображения", + "Quality": "Качество", + "low": "низкое", + "medium": "среднее", + "high": "высокое", + "auto": "авто", + "Size must use axb format with positive integer dimensions": "Размер должен быть в формате axb с положительными целыми значениями", + "Size width and height must be less than or equal to 3840": "Ширина и высота должны быть не больше 3840", + "请选择质量": "Выберите качество" } } diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 1d83bf6b7381..bfcafa361242 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -2811,7 +2811,9 @@ "站点额度展示类型及汇率": "Loại hiển thị hạn ngạch trang web và tỷ giá hối đoái", "端口号必须在1-65535之间": "Port number must be between 1-65535", "端口配置详细说明": "Hạn chế các yêu cầu bên ngoài đến các cổng cụ thể. Sử dụng cổng đơn (80, 443) hoặc phạm vi (8000-8999). Danh sách trống cho phép tất cả các cổng. Mặc định bao gồm các cổng web phổ biến.", - "端点": "Điểm cuối", + "端点(自动识别)": "Điểm cuối (tự động nhận diện)", + "生成的图片 {{index}}": "Ảnh đã tạo {{index}}", + "图片数据缺失": "Thiếu dữ liệu hình ảnh", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "URL endpoint phải là địa chỉ đầy đủ (bắt đầu bằng http:// hoặc https://)", "端点映射": "Ánh xạ điểm cuối", "端点类型": "Loại điểm cuối", @@ -4183,6 +4185,18 @@ "默认用户消息": "Xin chào", "默认补全倍率": "Tỷ lệ hoàn thành mặc định", "阶梯计费(表达式解析失败)": "Thanh toán theo bậc (không phân tích được biểu thức)", - "阶梯计费(未匹配到对应阶梯)": "Thanh toán theo bậc (không tìm thấy bậc phù hợp)" + "阶梯计费(未匹配到对应阶梯)": "Thanh toán theo bậc (không tìm thấy bậc phù hợp)", + "Endpoint (auto-detected)": "Điểm cuối (tự động nhận diện)", + "Auto-detected": "Tự động nhận diện", + "Image quality": "Chất lượng ảnh", + "Image size": "Kích thước ảnh", + "Quality": "Chất lượng", + "low": "thấp", + "medium": "trung bình", + "high": "cao", + "auto": "tự động", + "Size must use axb format with positive integer dimensions": "Kích thước phải dùng định dạng axb với chiều rộng và chiều cao là số nguyên dương", + "Size width and height must be less than or equal to 3840": "Chiều rộng và chiều cao phải nhỏ hơn hoặc bằng 3840", + "请选择质量": "Vui lòng chọn chất lượng" } } diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index 2156b46d8ee2..fe05d4957dcc 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -2556,7 +2556,9 @@ "站点额度展示类型及汇率": "站点额度展示类型及汇率", "端口号必须在1-65535之间": "端口号必须在1-65535之间", "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", - "端点": "端点", + "端点(自动识别)": "端点(自动识别)", + "生成的图片 {{index}}": "生成的图片 {{index}}", + "图片数据缺失": "图片数据缺失", "端点 URL 必须以 http:// 或 https:// 开头:": "端点 URL 必须以 http:// 或 https:// 开头:", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)", "端点映射": "端点映射", @@ -3778,6 +3780,18 @@ "缓存创建-1h": "缓存创建-1h", "见上方动态计费详情": "见上方动态计费详情", "含时间条件": "含时间条件", - "含请求条件": "含请求条件" + "含请求条件": "含请求条件", + "Endpoint (auto-detected)": "端点(自动识别)", + "Auto-detected": "自动识别", + "Image quality": "图片质量", + "Image size": "图片大小", + "Quality": "质量", + "low": "低", + "medium": "中", + "high": "高", + "auto": "自动", + "Size must use axb format with positive integer dimensions": "大小必须使用 axb 格式,且宽高为正整数", + "Size width and height must be less than or equal to 3840": "大小的宽和高都必须小于或等于 3840", + "请选择质量": "请选择质量" } } diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index 98d8e892488e..17a30c58ac65 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -2566,7 +2566,9 @@ "站点额度展示类型及汇率": "站點額度展示類型及匯率", "端口号必须在1-65535之间": "端口號必須在1-65535之間", "端口配置详细说明": "限制外部請求只能訪問指定端口。支援單個端口(80, 443)或端口範圍(8000-8999)。空列表允許所有端口。預設包含常用Web端口。", - "端点": "端點", + "端点(自动识别)": "端點(自動識別)", + "生成的图片 {{index}}": "生成的圖片 {{index}}", + "图片数据缺失": "圖片資料缺失", "端点 URL 必须以 http:// 或 https:// 开头:": "端點 URL 必須以 http:// 或 https:// 開頭:", "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端點 URL 必須是完整位址(以 http:// 或 https:// 開頭)", "端点映射": "端點映射", @@ -3642,6 +3644,18 @@ "默认用户消息": "你好", "默认补全倍率": "預設補全倍率", "阶梯计费(表达式解析失败)": "階梯計費(表達式解析失敗)", - "阶梯计费(未匹配到对应阶梯)": "階梯計費(未匹配到對應階梯)" + "阶梯计费(未匹配到对应阶梯)": "階梯計費(未匹配到對應階梯)", + "Endpoint (auto-detected)": "端點(自動識別)", + "Auto-detected": "自動識別", + "Image quality": "圖片品質", + "Image size": "圖片大小", + "Quality": "品質", + "low": "低", + "medium": "中", + "high": "高", + "auto": "自動", + "Size must use axb format with positive integer dimensions": "大小必須使用 axb 格式,且寬高為正整數", + "Size width and height must be less than or equal to 3840": "大小的寬和高都必須小於或等於 3840", + "请选择质量": "請選擇品質" } } diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index e23930f5e1d6..8c4e9f09ddfe 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -1777,7 +1777,9 @@ "站点额度展示类型及汇率": "站点额度展示类型及汇率", "端口号必须在1-65535之间": "端口号必须在1-65535之间", "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", - "端点": "端点", + "端点(自动识别)": "端点(自动识别)", + "生成的图片 {{index}}": "生成的图片 {{index}}", + "图片数据缺失": "图片数据缺失", "端点映射": "端点映射", "端点类型": "端点类型", "端点组": "端点组", @@ -2561,7 +2563,6 @@ "默认补全倍率": "默认补全倍率", "每日签到": "每日签到", "今日已签到,累计签到": "今日已签到,累计签到", - "天": "天", "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励", "今日已签到": "今日已签到", "立即签到": "立即签到", @@ -2595,6 +2596,18 @@ "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。", "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。", "阶梯计费(表达式解析失败)": "阶梯计费(表达式解析失败)", - "阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)" + "阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)", + "Endpoint (auto-detected)": "端点(自动识别)", + "Auto-detected": "自动识别", + "Image quality": "图片质量", + "Image size": "图片大小", + "Quality": "质量", + "low": "低", + "medium": "中", + "high": "高", + "auto": "自动", + "Size must use axb format with positive integer dimensions": "大小必须使用 axb 格式,且宽高为正整数", + "Size width and height must be less than or equal to 3840": "大小的宽和高都必须小于或等于 3840", + "请选择质量": "请选择质量" } } diff --git a/web/classic/src/pages/Playground/index.jsx b/web/classic/src/pages/Playground/index.jsx index 68a97c335bde..8a9f909c6638 100644 --- a/web/classic/src/pages/Playground/index.jsx +++ b/web/classic/src/pages/Playground/index.jsx @@ -38,6 +38,7 @@ import { useDataLoader } from '../../hooks/playground/useDataLoader'; import { MESSAGE_ROLES, ERROR_MESSAGES, + PLAYGROUND_ENDPOINTS, } from '../../constants/playground.constants'; import { getLogo, @@ -48,7 +49,12 @@ import { getTextContent, buildApiPayload, encodeToBase64, + inferPlaygroundEndpoint, } from '../../helpers'; +import { + isImageGenerationEndpoint, + validateImageSize, +} from '../../helpers/playgroundValidation'; // Components import { @@ -120,6 +126,10 @@ const Playground = () => { setCustomRequestBody, } = state; + const inferredEndpoint = inferPlaygroundEndpoint(inputs.model); + const effectiveEndpoint = + inputs.endpointOverride || inferredEndpoint || PLAYGROUND_ENDPOINTS.CHAT_COMPLETIONS; + // API 请求相关 const { sendRequest, onStopGenerator } = useApiRequest( setMessage, @@ -146,6 +156,7 @@ const Playground = () => { parameterEnabled, sendRequest, saveMessagesImmediately, + effectiveEndpoint, ); // 消息和自定义请求体同步 @@ -228,16 +239,21 @@ const Playground = () => { } } - return buildApiPayload(messages, null, inputs, parameterEnabled); + return buildApiPayload( + messages, + null, + inputs, + parameterEnabled, + effectiveEndpoint, + ); } catch (error) { console.error('构造预览请求体失败:', error); return null; } - }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]); + }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody, effectiveEndpoint]); // 发送消息 function onMessageSend(content, attachment) { - console.log('attachment: ', attachment); // 创建用户消息和加载消息 const userMessage = createMessage(MESSAGE_ROLES.USER, content); @@ -252,7 +268,7 @@ const Playground = () => { const newMessages = [...prevMessage, userMessage, loadingMessage]; // 发送自定义请求体 - sendRequest(customPayload, customPayload.stream !== false); + sendRequest(customPayload, customPayload.stream !== false, effectiveEndpoint); // 发送消息后保存,传入新消息列表 setTimeout(() => saveMessagesImmediately(newMessages), 0); @@ -269,6 +285,14 @@ const Playground = () => { // 默认模式 const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== ''); + if (isImageGenerationEndpoint(effectiveEndpoint)) { + const imageSizeError = validateImageSize(inputs.image_size); + if (imageSizeError) { + Toast.error(t(imageSizeError)); + return; + } + } + const messageContent = buildMessageContent( content, validImageUrls, @@ -287,8 +311,13 @@ const Playground = () => { null, inputs, parameterEnabled, + effectiveEndpoint, + ); + sendRequest( + payload, + inputs.stream && effectiveEndpoint !== PLAYGROUND_ENDPOINTS.IMAGE_GENERATIONS, + effectiveEndpoint, ); - sendRequest(payload, inputs.stream); // 禁用图片模式 if (inputs.imageEnabled) { @@ -319,6 +348,18 @@ const Playground = () => { [setMessage], ); + const handleEndpointChange = useCallback( + (value) => { + if (value === inferredEndpoint) { + handleInputChange('endpointOverride', null); + return; + } + + handleInputChange('endpointOverride', value); + }, + [handleInputChange, inferredEndpoint], + ); + // 渲染函数 const renderCustomChatContent = useCallback( ({ message, className }) => { @@ -483,7 +524,15 @@ const Playground = () => { showDebugPanel={showDebugPanel} customRequestMode={customRequestMode} customRequestBody={customRequestBody} - onInputChange={handleInputChange} + activeEndpoint={effectiveEndpoint} + inferredEndpoint={inferredEndpoint} + onEndpointChange={handleEndpointChange} + onInputChange={(name, value) => { + handleInputChange(name, value); + if (name === 'model') { + handleInputChange('endpointOverride', null); + } + }} onParameterToggle={handleParameterToggle} onCloseSettings={() => setShowSettings(false)} onConfigImport={handleConfigImport} diff --git a/web/default/src/components/ui/select.tsx b/web/default/src/components/ui/select.tsx index 65d4cde41681..481d06141480 100644 --- a/web/default/src/components/ui/select.tsx +++ b/web/default/src/components/ui/select.tsx @@ -152,7 +152,7 @@ function SelectItem({ )} {...props} > - + {children} = { + 'chat-completions': API_ENDPOINTS.CHAT_COMPLETIONS, + responses: API_ENDPOINTS.RESPONSES, + 'claude-messages': API_ENDPOINTS.CLAUDE_MESSAGES, + 'image-generations': API_ENDPOINTS.IMAGE_GENERATIONS, +} + +export async function sendPlaygroundRequest( + endpoint: PlaygroundEndpoint, + payload: PlaygroundRequest +): Promise { + const res = await api.post(ENDPOINT_URLS[endpoint], payload, { + skipErrorHandler: true, + } as Record) + return res.data +} + /** * Send chat completion request (non-streaming) */ export async function sendChatCompletion( payload: ChatCompletionRequest ): Promise { - const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, { - skipErrorHandler: true, - } as Record) - return res.data + return sendPlaygroundRequest('chat-completions', payload) } /** @@ -51,6 +68,7 @@ export async function getUserModels(): Promise { return data.data.map((model: string) => ({ label: model, value: model, + endpoint: inferPlaygroundEndpoint(model), })) } diff --git a/web/default/src/features/playground/components/playground-chat.tsx b/web/default/src/features/playground/components/playground-chat.tsx index 867ff93311ce..da0925ba9d11 100644 --- a/web/default/src/features/playground/components/playground-chat.tsx +++ b/web/default/src/features/playground/components/playground-chat.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' @@ -80,6 +81,7 @@ export function PlaygroundChat({ onCancelEdit, onSaveEditAndSubmit, }: PlaygroundChatProps) { + const { t } = useTranslation() const [editText, setEditText] = useState('') const [originalText, setOriginalText] = useState('') @@ -248,16 +250,51 @@ export function PlaygroundChat({ {actions} ) : ( - showMessageContent && ( + (showMessageContent || + !!message.images?.length) && ( <> - - {displayContent} - + {showMessageContent && ( + + {displayContent} + + )} + {!!message.images?.length && ( +
+ {message.images.map( + (image, imageIndex) => { + const src = image.url + ? image.url + : image.b64_json + ? `data:${image.mime_type || 'image/png'};base64,${image.b64_json}` + : '' + if (!src) return null + return ( + + {t('Generated + + ) + } + )} +
+ )} {actions} ) diff --git a/web/default/src/features/playground/components/playground-input.tsx b/web/default/src/features/playground/components/playground-input.tsx index f29265833918..05319d144771 100644 --- a/web/default/src/features/playground/components/playground-input.tsx +++ b/web/default/src/features/playground/components/playground-input.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { PaperclipIcon, FileIcon, @@ -40,6 +40,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { PromptInput, PromptInputButton, @@ -50,7 +57,14 @@ import { } from '@/components/ai-elements/prompt-input' import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion' import { ModelGroupSelector } from '@/components/model-group-selector' -import type { ModelOption, GroupOption } from '../types' +import { Input } from '@/components/ui/input' +import { + IMAGE_QUALITY_OPTIONS, + isImageGenerationEndpoint, + validateImageSize, +} from '../lib/validation' +import { getEndpointDescription, getEndpointLabel } from '../lib' +import type { ModelOption, GroupOption, PlaygroundEndpoint } from '../types' interface PlaygroundInputProps { onSubmit: (text: string) => void @@ -64,6 +78,13 @@ interface PlaygroundInputProps { groups: GroupOption[] groupValue: string onGroupChange: (value: string) => void + endpointValue: PlaygroundEndpoint + inferredEndpoint: PlaygroundEndpoint + onEndpointChange: (value: PlaygroundEndpoint) => void + imageQuality: string + imageSize: string + onImageQualityChange: (value: string) => void + onImageSizeChange: (value: string) => void } const suggestions = [ @@ -75,6 +96,18 @@ const suggestions = [ { icon: null, text: 'More' }, ] +const endpointOptions: PlaygroundEndpoint[] = [ + 'chat-completions', + 'responses', + 'claude-messages', + 'image-generations', +] + +const endpointSelectLabelId = 'playground-endpoint-select-label' +const imageQualitySelectLabelId = 'playground-image-quality-select-label' +const imageSizeInputId = 'playground-image-size-input' +const imageSizeErrorId = 'playground-image-size-error' + export function PlaygroundInput({ onSubmit, onStop, @@ -87,6 +120,13 @@ export function PlaygroundInput({ groups, groupValue, onGroupChange, + endpointValue, + inferredEndpoint, + onEndpointChange, + imageQuality, + imageSize, + onImageQualityChange, + onImageSizeChange, }: PlaygroundInputProps) { const { t } = useTranslation() const [text, setText] = useState('') @@ -94,6 +134,11 @@ export function PlaygroundInput({ const isModelSelectDisabled = disabled || isModelLoading || models.length === 0 const isGroupSelectDisabled = disabled || groups.length === 0 + const showImageOptions = isImageGenerationEndpoint(endpointValue) + const imageSizeError = useMemo( + () => (showImageOptions ? validateImageSize(imageSize) : null), + [showImageOptions, imageSize] + ) const handleSubmit = (message: PromptInputMessage) => { if (!message.text?.trim() || disabled) return @@ -126,6 +171,109 @@ export function PlaygroundInput({ value={text} /> +
+ + +
+ + {t('playground.endpoint.autoInferredLabel')} + + +
+ + {showImageOptions && ( + <> +
+ + {t('playground.image.quality')} + + +
+ +
+ + onImageSizeChange(event.target.value)} + placeholder='1024x1024' + value={imageSize} + /> + {imageSizeError && ( + + {imageSizeError} + + )} +
+ + )} +
+ @@ -183,16 +331,6 @@ export function PlaygroundInput({
- - {isGenerating && onStop ? ( . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { toast } from 'sonner' -import { sendChatCompletion } from '../api' +import { sendPlaygroundRequest } from '../api' import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants' import { - buildChatCompletionPayload, + buildPlaygroundPayload, + finalizeMessage, + inferPlaygroundEndpoint, + normalizePlaygroundError, + normalizePlaygroundResponse, + processStreamingContent, updateAssistantMessageWithError, + updateCurrentVersionContent, updateLastAssistantMessage, - processStreamingContent, - finalizeMessage, } from '../lib' +import { isImageGenerationEndpoint } from '../lib/validation' import type { Message, PlaygroundConfig, ParameterEnabled } from '../types' import { useStreamRequest } from './use-stream-request' @@ -45,16 +50,19 @@ export function useChatHandler({ onMessageUpdate, }: UseChatHandlerOptions) { const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest() + const endpoint = useMemo( + () => config.endpointOverride ?? inferPlaygroundEndpoint(config.model), + [config.endpointOverride, config.model] + ) // Handle stream update const handleStreamUpdate = useCallback( - (type: 'reasoning' | 'content', chunk: string) => { + ({ type, chunk }: { type: 'reasoning' | 'content'; chunk: string }) => { onMessageUpdate((prev) => updateLastAssistantMessage(prev, (message) => { if (message.status === MESSAGE_STATUS.ERROR) return message if (type === 'reasoning') { - // Direct API reasoning_content return { ...message, reasoning: { @@ -66,7 +74,6 @@ export function useChatHandler({ } } - // Content streaming: handle tags return { ...processStreamingContent(message, chunk), status: MESSAGE_STATUS.STREAMING, @@ -78,16 +85,35 @@ export function useChatHandler({ ) // Handle stream complete - const handleStreamComplete = useCallback(() => { - onMessageUpdate((prev) => - updateLastAssistantMessage(prev, (message) => - message.status === MESSAGE_STATUS.COMPLETE || - message.status === MESSAGE_STATUS.ERROR - ? message - : { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE } + const handleStreamComplete = useCallback( + (result?: { content?: string; reasoning?: string; images?: Message['images'] }) => { + onMessageUpdate((prev) => + updateLastAssistantMessage(prev, (message) => { + if ( + message.status === MESSAGE_STATUS.COMPLETE || + message.status === MESSAGE_STATUS.ERROR + ) { + return message + } + + const withCompletedContent = result?.content + ? updateCurrentVersionContent(message, result.content) + : message + const finalized = finalizeMessage( + withCompletedContent, + result?.reasoning + ) + + return { + ...finalized, + images: result?.images?.length ? result.images : finalized.images, + status: MESSAGE_STATUS.COMPLETE, + } + }) ) - ) - }, [onMessageUpdate]) + }, + [onMessageUpdate] + ) // Handle stream error const handleStreamError = useCallback( @@ -103,12 +129,14 @@ export function useChatHandler({ // Send streaming chat request const sendStreamingChat = useCallback( (messages: Message[]) => { - const payload = buildChatCompletionPayload( + const payload = buildPlaygroundPayload( + endpoint, messages, config, parameterEnabled ) sendStreamRequest( + endpoint, payload, handleStreamUpdate, handleStreamComplete, @@ -116,6 +144,7 @@ export function useChatHandler({ ) }, [ + endpoint, config, parameterEnabled, sendStreamRequest, @@ -128,62 +157,48 @@ export function useChatHandler({ // Send non-streaming chat request const sendNonStreamingChat = useCallback( async (messages: Message[]) => { - const payload = buildChatCompletionPayload( + const payload = buildPlaygroundPayload( + endpoint, messages, config, parameterEnabled ) try { - const response = await sendChatCompletion(payload) - const choice = response.choices?.[0] - if (!choice) return + const response = await sendPlaygroundRequest(endpoint, payload) + const normalized = normalizePlaygroundResponse(endpoint, response) onMessageUpdate((prev) => updateLastAssistantMessage(prev, (message) => ({ ...finalizeMessage( - { - ...message, - versions: [ - { - ...message.versions[0], - content: choice.message?.content || '', - }, - ], - }, - choice.message?.reasoning_content + updateCurrentVersionContent(message, normalized.content), + normalized.reasoning ), + images: normalized.images?.length ? normalized.images : message.images, status: MESSAGE_STATUS.COMPLETE, })) ) } catch (error: unknown) { - const err = error as { - response?: { - data?: { message?: string; error?: { code?: string } } - } - message?: string - } + const normalized = normalizePlaygroundError(error) handleStreamError( - err?.response?.data?.message || - err?.message || - ERROR_MESSAGES.API_REQUEST_ERROR, - err?.response?.data?.error?.code || undefined + normalized.message || ERROR_MESSAGES.API_REQUEST_ERROR, + normalized.code ) } }, - [config, parameterEnabled, onMessageUpdate, handleStreamError] + [endpoint, config, parameterEnabled, onMessageUpdate, handleStreamError] ) // Send chat request (stream or non-stream based on config) const sendChat = useCallback( (messages: Message[]) => { - if (config.stream) { + if (config.stream && !isImageGenerationEndpoint(endpoint)) { sendStreamingChat(messages) } else { sendNonStreamingChat(messages) } }, - [config.stream, sendStreamingChat, sendNonStreamingChat] + [config.stream, endpoint, sendStreamingChat, sendNonStreamingChat] ) // Stop generation diff --git a/web/default/src/features/playground/hooks/use-stream-request.ts b/web/default/src/features/playground/hooks/use-stream-request.ts index 05f2b2ffe20f..22902b4bda97 100644 --- a/web/default/src/features/playground/hooks/use-stream-request.ts +++ b/web/default/src/features/playground/hooks/use-stream-request.ts @@ -20,10 +20,34 @@ import { useCallback, useRef } from 'react' import { SSE } from 'sse.js' import { getCommonHeaders } from '@/lib/api' import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants' -import type { ChatCompletionRequest, ChatCompletionChunk } from '../types' +import { normalizePlaygroundResponse } from '../lib' +import type { + ChatCompletionChunk, + PlaygroundEndpoint, + PlaygroundImage, + PlaygroundRequest, +} from '../types' + +const ENDPOINT_URLS: Record = { + 'chat-completions': API_ENDPOINTS.CHAT_COMPLETIONS, + responses: API_ENDPOINTS.RESPONSES, + 'claude-messages': API_ENDPOINTS.CLAUDE_MESSAGES, + 'image-generations': API_ENDPOINTS.IMAGE_GENERATIONS, +} + +interface StreamUpdatePayload { + type: 'reasoning' | 'content' + chunk: string +} + +interface StreamCompletePayload { + content?: string + reasoning?: string + images?: PlaygroundImage[] +} /** - * Hook for handling streaming chat completion requests + * Hook for handling streaming playground requests */ export function useStreamRequest() { const sseSourceRef = useRef(null) @@ -31,12 +55,13 @@ export function useStreamRequest() { const sendStreamRequest = useCallback( ( - payload: ChatCompletionRequest, - onUpdate: (type: 'reasoning' | 'content', chunk: string) => void, - onComplete: () => void, + endpoint: PlaygroundEndpoint, + payload: PlaygroundRequest, + onUpdate: (payload: StreamUpdatePayload) => void, + onComplete: (payload?: StreamCompletePayload) => void, onError: (error: string, errorCode?: string) => void ) => { - const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { + const source = new SSE(ENDPOINT_URLS[endpoint], { headers: getCommonHeaders(), method: 'POST', payload: JSON.stringify(payload), @@ -57,35 +82,93 @@ export function useStreamRequest() { } } - source.addEventListener('message', (e: MessageEvent) => { - if (e.data === '[DONE]') { - isStreamCompleteRef.current = true - closeSource() - onComplete() + const completeStream = (payload?: StreamCompletePayload) => { + if (isStreamCompleteRef.current) return + isStreamCompleteRef.current = true + closeSource() + onComplete(payload) + } + + const parseData = (data: string, eventType?: string) => { + if (data === '[DONE]') { + completeStream() return } try { - const chunk: ChatCompletionChunk = JSON.parse(e.data) - const delta = chunk.choices?.[0]?.delta - - if (delta) { - if (delta.reasoning_content) { - onUpdate('reasoning', delta.reasoning_content) + const parsed = JSON.parse(data) as Record + + if (endpoint === 'responses') { + const type = typeof parsed.type === 'string' ? parsed.type : eventType + if ( + (type === 'response.output_text.delta' || + type === 'response.reasoning_text.delta') && + typeof parsed.delta === 'string' + ) { + onUpdate({ + type: + type === 'response.reasoning_text.delta' + ? 'reasoning' + : 'content', + chunk: parsed.delta, + }) } - if (delta.content) { - onUpdate('content', delta.content) + if (type === 'response.completed' && parsed.response) { + completeStream(normalizePlaygroundResponse('responses', parsed.response)) + } + return + } + + if (endpoint === 'claude-messages') { + const type = typeof parsed.type === 'string' ? parsed.type : eventType + const delta = parsed.delta as Record | undefined + if (type === 'content_block_delta' && delta) { + if (typeof delta.thinking === 'string') { + onUpdate({ type: 'reasoning', chunk: delta.thinking }) + } + if (typeof delta.text === 'string') { + onUpdate({ type: 'content', chunk: delta.text }) + } } + if (type === 'message_stop') completeStream() + return + } + + const chunk = parsed as unknown as ChatCompletionChunk + const delta = chunk.choices?.[0]?.delta + + if (delta?.reasoning_content) { + onUpdate({ type: 'reasoning', chunk: delta.reasoning_content }) + } + if (delta?.content) { + onUpdate({ type: 'content', chunk: delta.content }) } } catch (error) { // eslint-disable-next-line no-console console.error('Failed to parse SSE message:', error) handleError(ERROR_MESSAGES.PARSE_ERROR) } - }) + } + + const addDataListener = (eventName: string) => { + source.addEventListener(eventName, (e: MessageEvent) => { + parseData(e.data, eventName) + }) + } + + if (endpoint === 'responses') { + ;[ + 'response.output_text.delta', + 'response.reasoning_text.delta', + 'response.completed', + ].forEach(addDataListener) + } else if (endpoint === 'claude-messages') { + ;['content_block_delta', 'message_stop'].forEach(addDataListener) + } else { + addDataListener('message') + } source.addEventListener('error', (e: Event & { data?: string }) => { - // Only handle errors if stream didn't complete normally if (source.readyState !== 2) { // eslint-disable-next-line no-console console.error('SSE Error:', e) diff --git a/web/default/src/features/playground/index.tsx b/web/default/src/features/playground/index.tsx index 49d4a37c56be..ca85a200c6d4 100644 --- a/web/default/src/features/playground/index.tsx +++ b/web/default/src/features/playground/index.tsx @@ -16,17 +16,22 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' +import { toast } from 'sonner' import { getUserModels, getUserGroups } from './api' import { PlaygroundChat } from './components/playground-chat' import { PlaygroundInput } from './components/playground-input' import { DEFAULT_GROUP } from './constants' import { usePlaygroundState, useChatHandler } from './hooks' import { createUserMessage, createLoadingAssistantMessage } from './lib' -import type { Message as MessageType } from './types' +import { inferPlaygroundEndpoint } from './lib/endpoint' +import { isImageGenerationEndpoint, validateImageSize } from './lib/validation' +import type { Message as MessageType, PlaygroundEndpoint } from './types' export function Playground() { + const { t } = useTranslation() const { config, parameterEnabled, @@ -39,6 +44,12 @@ export function Playground() { updateConfig, } = usePlaygroundState() + const inferredEndpoint = useMemo( + () => inferPlaygroundEndpoint(config.model), + [config.model] + ) + const endpoint = config.endpointOverride ?? inferredEndpoint + const { sendChat, stopGeneration, isGenerating } = useChatHandler({ config, parameterEnabled, @@ -72,6 +83,7 @@ export function Playground() { const isCurrentModelValid = modelsData.some((m) => m.value === config.model) if (modelsData.length > 0 && !isCurrentModelValid) { updateConfig('model', modelsData[0].value) + updateConfig('endpointOverride', null) } }, [modelsData, config.model, setModels, updateConfig]) @@ -97,6 +109,14 @@ export function Playground() { }, [groupsData, setGroups]) const handleSendMessage = (text: string) => { + if (isImageGenerationEndpoint(endpoint)) { + const imageSizeError = validateImageSize(config.image_size) + if (imageSizeError) { + toast.error(t(imageSizeError)) + return + } + } + const userMessage = createUserMessage(text) const assistantMessage = createLoadingAssistantMessage() @@ -107,12 +127,6 @@ export function Playground() { sendChat(newMessages) } - const handleCopyMessage = (message: MessageType) => { - // Copy is handled in MessageActions component - // eslint-disable-next-line no-console - console.log('Message copied:', message.key) - } - const handleRegenerateMessage = (message: MessageType) => { // Find the message index and regenerate from there const messageIndex = messages.findIndex((m) => m.key === message.key) @@ -170,13 +184,26 @@ export function Playground() { updateMessages(newMessages) } + const handleModelChange = (value: string) => { + updateConfig('model', value) + updateConfig('endpointOverride', null) + } + + const handleEndpointChange = (value: PlaygroundEndpoint) => { + if (value === inferredEndpoint) { + updateConfig('endpointOverride', null) + return + } + + updateConfig('endpointOverride', value) + } + return (
{/* Full-width scroll container: scrolling works even over side whitespace */}
updateConfig('group', value)} - onModelChange={(value) => updateConfig('model', value)} + onImageQualityChange={(value) => updateConfig('image_quality', value)} + onImageSizeChange={(value) => updateConfig('image_size', value)} + imageQuality={config.image_quality} + imageSize={config.image_size} + onModelChange={handleModelChange} onStop={stopGeneration} onSubmit={handleSendMessage} /> diff --git a/web/default/src/features/playground/lib/endpoint.ts b/web/default/src/features/playground/lib/endpoint.ts new file mode 100644 index 000000000000..04a13cf8b752 --- /dev/null +++ b/web/default/src/features/playground/lib/endpoint.ts @@ -0,0 +1,71 @@ +import { t } from 'i18next' +import type { PlaygroundEndpoint } from '../types' + +const IMAGE_MODEL_PATTERNS = [ + 'gpt-image', + 'dall-e', + 'imagen-', + 'flux-', + 'flux.1-', +] + +export function inferPlaygroundEndpoint(model: string): PlaygroundEndpoint { + const normalized = model.toLowerCase() + + if (IMAGE_MODEL_PATTERNS.some((pattern) => normalized.includes(pattern))) { + return 'image-generations' + } + + if ( + normalized.includes('claude') || + normalized.includes('haiku') || + normalized.includes('sonnet') || + normalized.includes('opus') + ) { + return 'claude-messages' + } + + if ( + normalized.startsWith('gpt-') || + normalized.startsWith('chatgpt') || + /^o\d/.test(normalized) + ) { + return 'responses' + } + + return 'chat-completions' +} + +export function getEndpointLabel(endpoint: PlaygroundEndpoint): string { + switch (endpoint) { + case 'responses': + return t('playground.endpoint.label.responses') + case 'claude-messages': + return t('playground.endpoint.label.claude-messages') + case 'image-generations': + return t('playground.endpoint.label.image-generations') + case 'chat-completions': + return t('playground.endpoint.label.chat-completions') + default: { + const exhaustiveCheck: never = endpoint + return exhaustiveCheck + } + } +} + +export function getEndpointDescription(endpoint: PlaygroundEndpoint): string { + switch (endpoint) { + case 'responses': + return t('playground.endpoint.description.responses') + case 'claude-messages': + return t('playground.endpoint.description.claude-messages') + case 'image-generations': + return t('playground.endpoint.description.image-generations') + case 'chat-completions': + return t('playground.endpoint.description.chat-completions') + default: { + const exhaustiveCheck: never = endpoint + return exhaustiveCheck + } + } +} diff --git a/web/default/src/features/playground/lib/index.ts b/web/default/src/features/playground/lib/index.ts index e661bacf2ce8..85a34dd5111e 100644 --- a/web/default/src/features/playground/lib/index.ts +++ b/web/default/src/features/playground/lib/index.ts @@ -20,3 +20,5 @@ export * from './message-utils' export * from './payload-builder' export * from './storage' export * from './message-styles' +export * from './endpoint' +export * from './response-parser' diff --git a/web/default/src/features/playground/lib/payload-builder.ts b/web/default/src/features/playground/lib/payload-builder.ts index f623dbe6c2d0..f842441e81f9 100644 --- a/web/default/src/features/playground/lib/payload-builder.ts +++ b/web/default/src/features/playground/lib/payload-builder.ts @@ -21,8 +21,45 @@ import type { Message, PlaygroundConfig, ParameterEnabled, + ResponsesRequest, + ClaudeMessagesRequest, + ImageGenerationRequest, + PlaygroundEndpoint, + PlaygroundRequest, } from '../types' -import { formatMessageForAPI, isValidMessage } from './message-utils' +import { + formatMessageForAPI, + getCurrentVersion, + isValidMessage, +} from './message-utils' + +function getProcessedMessages(messages: Message[]) { + return messages.filter(isValidMessage).map(formatMessageForAPI) +} + +function getLastUserPrompt(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message?.from === 'user') { + return getCurrentVersion(message).content + } + } + return '' +} + +function applyCommonTextParameters( + payload: Record, + config: PlaygroundConfig, + parameterEnabled: ParameterEnabled, + maxTokenKey: 'max_tokens' | 'max_output_tokens' +) { + if (parameterEnabled.temperature) payload.temperature = config.temperature + if (parameterEnabled.top_p) payload.top_p = config.top_p + if (parameterEnabled.max_tokens) payload[maxTokenKey] = + maxTokenKey === 'max_output_tokens' + ? config.max_output_tokens + : config.max_tokens +} /** * Build API request payload from messages and config @@ -32,19 +69,13 @@ export function buildChatCompletionPayload( config: PlaygroundConfig, parameterEnabled: ParameterEnabled ): ChatCompletionRequest { - // Filter and format valid messages - const processedMessages = messages - .filter(isValidMessage) - .map(formatMessageForAPI) - const payload: ChatCompletionRequest = { model: config.model, group: config.group, - messages: processedMessages, + messages: getProcessedMessages(messages), stream: config.stream, } - // Add enabled parameters const parameterKeys: Array = [ 'temperature', 'top_p', @@ -65,3 +96,101 @@ export function buildChatCompletionPayload( return payload } + +export function buildResponsesPayload( + messages: Message[], + config: PlaygroundConfig, + parameterEnabled: ParameterEnabled +): ResponsesRequest { + const processedMessages = getProcessedMessages(messages) + const systemMessages = processedMessages.filter((m) => m.role === 'system') + const input = processedMessages.filter((m) => m.role !== 'system') + const payload: ResponsesRequest = { + model: config.model, + group: config.group, + input, + stream: config.stream, + } + + if (systemMessages.length > 0) { + payload.instructions = systemMessages + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter(Boolean) + .join('\n\n') + } + + applyCommonTextParameters( + payload as unknown as Record, + config, + parameterEnabled, + 'max_output_tokens' + ) + + return payload +} + +export function buildClaudeMessagesPayload( + messages: Message[], + config: PlaygroundConfig, + parameterEnabled: ParameterEnabled +): ClaudeMessagesRequest { + const processedMessages = getProcessedMessages(messages) + const systemMessages = processedMessages.filter((m) => m.role === 'system') + const payload: ClaudeMessagesRequest = { + model: config.model, + group: config.group, + messages: processedMessages.filter((m) => m.role !== 'system'), + stream: config.stream, + } + + if (systemMessages.length > 0) { + payload.system = systemMessages + .map((m) => (typeof m.content === 'string' ? m.content : '')) + .filter(Boolean) + .join('\n\n') + } + + applyCommonTextParameters( + payload as unknown as Record, + config, + parameterEnabled, + 'max_tokens' + ) + + return payload +} + +export function buildImageGenerationPayload( + messages: Message[], + config: PlaygroundConfig +): ImageGenerationRequest { + const payload: ImageGenerationRequest = { + model: config.model, + group: config.group, + prompt: getLastUserPrompt(messages), + n: config.image_n, + size: config.image_size, + quality: config.image_quality, + response_format: config.image_response_format, + } + + return payload +} + +export function buildPlaygroundPayload( + endpoint: PlaygroundEndpoint, + messages: Message[], + config: PlaygroundConfig, + parameterEnabled: ParameterEnabled +): PlaygroundRequest { + switch (endpoint) { + case 'responses': + return buildResponsesPayload(messages, config, parameterEnabled) + case 'claude-messages': + return buildClaudeMessagesPayload(messages, config, parameterEnabled) + case 'image-generations': + return buildImageGenerationPayload(messages, config) + default: + return buildChatCompletionPayload(messages, config, parameterEnabled) + } +} diff --git a/web/default/src/features/playground/lib/response-parser.ts b/web/default/src/features/playground/lib/response-parser.ts new file mode 100644 index 000000000000..cedb78fc5a33 --- /dev/null +++ b/web/default/src/features/playground/lib/response-parser.ts @@ -0,0 +1,198 @@ +import { t } from 'i18next' +import type { PlaygroundEndpoint, PlaygroundImage } from '../types' + +interface NormalizedPlaygroundResponse { + content: string + reasoning?: string + images?: PlaygroundImage[] +} + +function extractTextFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + + return content + .map((part) => { + if (!part || typeof part !== 'object') return '' + const record = part as Record + if (typeof record.text === 'string') return record.text + if (typeof record.content === 'string') return record.content + if (record.type === 'text' && typeof record.value === 'string') { + return record.value + } + return '' + }) + .filter(Boolean) + .join('\n') +} + +function extractImages(value: unknown): PlaygroundImage[] { + const images: PlaygroundImage[] = [] + + const visit = (node: unknown) => { + if (!node || typeof node !== 'object') return + + if (Array.isArray(node)) { + node.forEach(visit) + return + } + + const record = node as Record + const maybeImage: PlaygroundImage = {} + + if (typeof record.url === 'string') maybeImage.url = record.url + if (typeof record.image_url === 'string') maybeImage.url = record.image_url + if (typeof record.b64_json === 'string') maybeImage.b64_json = record.b64_json + if (typeof record.result === 'string') maybeImage.b64_json = record.result + if (typeof record.mime_type === 'string') maybeImage.mime_type = record.mime_type + + if (maybeImage.url || maybeImage.b64_json) { + images.push(maybeImage) + } + + Object.values(record).forEach(visit) + } + + visit(value) + return images +} + +function normalizeChatCompletionResponse(response: unknown): NormalizedPlaygroundResponse { + const record = response as { + choices?: Array<{ + message?: { content?: string; reasoning_content?: string } + }> + } + const choice = record.choices?.[0] + return { + content: choice?.message?.content || '', + reasoning: choice?.message?.reasoning_content, + } +} + +function normalizeResponsesResponse(response: unknown): NormalizedPlaygroundResponse { + const record = response as Record + const images = extractImages(record.output) + const outputText = typeof record.output_text === 'string' ? record.output_text : '' + + if (outputText) { + return { content: outputText, images } + } + + const output = Array.isArray(record.output) ? record.output : [] + const content = output + .map((item) => { + if (!item || typeof item !== 'object') return '' + const itemRecord = item as Record + return extractTextFromContent(itemRecord.content) + }) + .filter(Boolean) + .join('\n') + + return { content, images } +} + +function normalizeClaudeResponse(response: unknown): NormalizedPlaygroundResponse { + const record = response as Record + const content = extractTextFromContent(record.content) + const reasoning = Array.isArray(record.content) + ? record.content + .map((part) => { + if (!part || typeof part !== 'object') return '' + const partRecord = part as Record + if ( + (partRecord.type === 'thinking' || partRecord.type === 'reasoning') && + typeof partRecord.thinking === 'string' + ) { + return partRecord.thinking + } + if ( + (partRecord.type === 'thinking' || partRecord.type === 'reasoning') && + typeof partRecord.text === 'string' + ) { + return partRecord.text + } + return '' + }) + .filter(Boolean) + .join('\n') + : '' + + return { content, reasoning } +} + +function normalizeImageGenerationResponse(response: unknown): NormalizedPlaygroundResponse { + const record = response as Record + return { + content: '', + images: extractImages(record.data), + } +} + +export function normalizePlaygroundResponse( + endpoint: PlaygroundEndpoint, + response: unknown +): NormalizedPlaygroundResponse { + switch (endpoint) { + case 'responses': + return normalizeResponsesResponse(response) + case 'claude-messages': + return normalizeClaudeResponse(response) + case 'image-generations': + return normalizeImageGenerationResponse(response) + default: + return normalizeChatCompletionResponse(response) + } +} + +export function normalizePlaygroundError(error: unknown): { + message: string + code?: string +} { + const err = error as { + response?: { + status?: number + statusText?: string + data?: + | string + | { + message?: string + error?: { message?: string; code?: string } + } + } + message?: string + } + + const status = err?.response?.status + const responseData = err?.response?.data + + if (status === 504) { + return { + message: t('errors.gatewayTimeout'), + code: undefined, + } + } + + if (typeof responseData === 'string') { + return { + message: status + ? t('errors.httpError', { + status, + statusText: err?.response?.statusText + ? `: ${err.response.statusText}` + : '', + }) + : t('errors.requestError'), + code: undefined, + } + } + + return { + message: + responseData?.error?.message || + responseData?.message || + err?.message || + t('errors.requestError'), + code: responseData?.error?.code || undefined, + } +} diff --git a/web/default/src/features/playground/lib/validation.ts b/web/default/src/features/playground/lib/validation.ts new file mode 100644 index 000000000000..9c9fd3fc1134 --- /dev/null +++ b/web/default/src/features/playground/lib/validation.ts @@ -0,0 +1,28 @@ +import { t } from 'i18next' +import type { PlaygroundEndpoint } from '../types' + +export const IMAGE_QUALITY_OPTIONS = ['low', 'medium', 'high', 'auto'] as const + +export const IMAGE_SIZE_PATTERN = /^([1-9]\d*)[xX]([1-9]\d*)$/ + +export function validateImageSize(size: string): string | null { + const trimmedSize = size.trim() + const match = IMAGE_SIZE_PATTERN.exec(trimmedSize) + + if (!match) return t('playground.image.size.formatError') + + const width = Number(match[1]) + const height = Number(match[2]) + + if (width > 3840 || height > 3840) { + return t('playground.image.size.limitError') + } + + return null +} + +export function isImageGenerationEndpoint( + endpoint: PlaygroundEndpoint +): endpoint is 'image-generations' { + return endpoint === 'image-generations' +} diff --git a/web/default/src/features/playground/types.ts b/web/default/src/features/playground/types.ts index 11a42e3c4cb1..15f35c51c20f 100644 --- a/web/default/src/features/playground/types.ts +++ b/web/default/src/features/playground/types.ts @@ -21,6 +21,18 @@ export type MessageRole = 'user' | 'assistant' | 'system' export type MessageStatus = 'loading' | 'streaming' | 'complete' | 'error' +export type PlaygroundEndpoint = + | 'chat-completions' + | 'responses' + | 'claude-messages' + | 'image-generations' + +export interface PlaygroundImage { + url?: string + b64_json?: string + mime_type?: string +} + export interface MessageVersion { id: string content: string @@ -31,6 +43,7 @@ export interface Message { from: MessageRole versions: MessageVersion[] sources?: { href: string; title: string }[] + images?: PlaygroundImage[] reasoning?: { content: string duration: number @@ -69,6 +82,45 @@ export interface ChatCompletionRequest { seed?: number } +export interface ResponsesRequest { + model: string + group?: string + input: ChatCompletionMessage[] + instructions?: string + stream?: boolean + temperature?: number + top_p?: number + max_output_tokens?: number + tools?: Array> +} + +export interface ClaudeMessagesRequest { + model: string + group?: string + system?: string + messages: ChatCompletionMessage[] + stream?: boolean + temperature?: number + top_p?: number + max_tokens?: number +} + +export interface ImageGenerationRequest { + model: string + group?: string + prompt: string + n?: number + size?: string + quality?: string + response_format?: string +} + +export type PlaygroundRequest = + | ChatCompletionRequest + | ResponsesRequest + | ClaudeMessagesRequest + | ImageGenerationRequest + export interface ChatCompletionChunk { id: string object: string @@ -110,13 +162,19 @@ export interface ChatCompletionResponse { export interface PlaygroundConfig { model: string group: string + endpointOverride: PlaygroundEndpoint | null temperature: number top_p: number max_tokens: number + max_output_tokens: number frequency_penalty: number presence_penalty: number seed: number | null stream: boolean + image_size: string + image_quality: string + image_n: number + image_response_format: string } export interface ParameterEnabled { @@ -128,10 +186,10 @@ export interface ParameterEnabled { seed: boolean } -// Model and group options export interface ModelOption { label: string value: string + endpoint?: PlaygroundEndpoint } export interface GroupOption { diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 05e8c5bd4296..98631d7a37ce 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Related Projects", "footer.defaultCopyright": "All rights reserved.", - "footer.new\u0061pi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.", + "footer.newapi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment", "For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Force a syntactically valid JSON response", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "Generate new backup codes for account recovery", "Generate New Codes": "Generate New Codes", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.", - "Generated image": "Generated image", + "Generated image": "Generated image {{number}}", "Generating new codes will invalidate all existing backup codes.": "Generating new codes will invalidate all existing backup codes.", "Generating...": "Generating...", "Generation quality preset": "Generation quality preset", @@ -4403,6 +4403,24 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "playground.image.zoom": "Zoom", + "playground.endpoint.autoInferredLabel": "Endpoint (auto inferred)", + "playground.endpoint.autoInferred": "Auto inferred", + "playground.image.quality": "Image quality", + "playground.image.size": "Image size", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "GPT text models, including Responses image_generation_call results.", + "playground.endpoint.description.claude-messages": "Claude Haiku, Sonnet, and Opus models.", + "playground.endpoint.description.image-generations": "Dedicated image models such as gpt-image and dall-e.", + "playground.endpoint.description.chat-completions": "Legacy OpenAI-compatible chat completion models.", + "errors.gatewayTimeout": "Gateway timeout. The image generation request took too long. Please try again later.", + "errors.httpError": "HTTP error {{status}}{{statusText}}", + "errors.requestError": "Request error occurred", + "playground.image.size.formatError": "Size must use axb format with positive integer dimensions", + "playground.image.size.limitError": "Size width and height must be less than or equal to 3840", + "playground.image.quality.select": "Please select quality" } } diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index f13d1b8bf368..9b92c5be3eac 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Projets liés", "footer.defaultCopyright": "Tous droits réservés.", - "footer.new\u0061pi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.", + "footer.newapi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Pour les canaux ajoutés après le 10 mai 2025, pas besoin de supprimer \".\" des noms de modèles lors du déploiement", "For private deployments, format: https://fastgpt.run/api/openapi": "Pour les déploiements privés, format : https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Imposer une réponse JSON syntaxiquement valide", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "Générer de nouveaux codes de secours pour la récupération du compte", "Generate New Codes": "Générer de nouveaux codes", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "Générez des jetons depuis la page Jetons ; vous pouvez les restreindre par modèle, groupe, IP et limites de débit.", - "Generated image": "Image générée", + "Generated image": "Image générée {{number}}", "Generating new codes will invalidate all existing backup codes.": "La génération de nouveaux codes invalidera tous les codes de sauvegarde existants.", "Generating...": "Génération...", "Generation quality preset": "Préréglage de qualité de génération", @@ -4403,6 +4403,24 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "playground.image.zoom": "Zoom", + "playground.endpoint.autoInferredLabel": "Point de terminaison (détection automatique)", + "playground.endpoint.autoInferred": "Détection automatique", + "playground.image.quality": "Qualité de l’image", + "playground.image.size": "Taille de l’image", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "Modèles de texte GPT, y compris les résultats Responses image_generation_call.", + "playground.endpoint.description.claude-messages": "Modèles Claude Haiku, Sonnet et Opus.", + "playground.endpoint.description.image-generations": "Modèles d’image dédiés tels que gpt-image et dall-e.", + "playground.endpoint.description.chat-completions": "Modèles de chat completion OpenAI compatibles hérités.", + "errors.gatewayTimeout": "Délai d'attente dépassé. La requête de génération d'image a pris trop de temps. Veuillez réessayer plus tard.", + "errors.httpError": "Erreur HTTP {{status}} {{statusText}}", + "errors.requestError": "Une erreur de requête s'est produite", + "playground.image.size.formatError": "La taille doit utiliser le format axb avec des dimensions entières positives", + "playground.image.size.limitError": "La largeur et la hauteur doivent être inférieures ou égales à 3840", + "playground.image.quality.select": "Veuillez choisir la qualité" } } diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 7a17e1cd5f91..ffa3fc7fa206 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "1つのAPI", "footer.columns.related.title": "関連プロジェクト", "footer.defaultCopyright": "すべての権利を留保します。", - "footer.new\u0061pi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。", + "footer.newapi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "2025 年 5 月 10 日以降に追加されたチャネルの場合、デプロイ時にモデル名から「.」を削除する必要はありません", "For private deployments, format: https://fastgpt.run/api/openapi": "プライベートデプロイメントの場合、形式: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "構文的に有効な JSON 応答を強制", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "アカウント復旧用の新しいバックアップコードを生成", "Generate New Codes": "新しいコードを生成", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "トークンページから API キーを発行できます。モデル、グループ、IP、レート制限ごとに細かく権限を設定可能です。", - "Generated image": "生成された画像", + "Generated image": "生成された画像 {{number}}", "Generating new codes will invalidate all existing backup codes.": "新しいコードを生成すると、既存のすべてのバックアップコードが無効になります。", "Generating...": "生成中...", "Generation quality preset": "生成品質プリセット", @@ -4403,6 +4403,21 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム" + "playground.image.zoom": "ズーム", + "playground.endpoint.autoInferredLabel": "エンドポイント(自動判定)", + "playground.endpoint.autoInferred": "自動判定", + "playground.image.quality": "画像品質", + "playground.image.size": "画像サイズ", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "Responses の image_generation_call 結果を含む GPT テキストモデルです。", + "playground.endpoint.description.claude-messages": "Claude Haiku、Sonnet、Opus モデルです。", + "playground.endpoint.description.image-generations": "gpt-image や dall-e などの専用画像モデルです。", + "playground.endpoint.description.chat-completions": "従来の OpenAI 互換チャット補完モデルです。", + "playground.image.size.formatError": "サイズは正の整数の axb 形式で入力してください", + "playground.image.size.limitError": "サイズの幅と高さはどちらも 3840 以下である必要があります", + "playground.image.quality.select": "品質を選択してください" } } diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 91f8030ee433..b564493089de 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "Один API", "footer.columns.related.title": "Связанные проекты", "footer.defaultCopyright": "Все права защищены.", - "footer.new\u0061pi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.", + "footer.newapi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Для каналов, добавленных после 10 мая 2025 г., не нужно удалять \".\" из имён моделей при развёртывании", "For private deployments, format: https://fastgpt.run/api/openapi": "Для частных развертываний, формат: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Принудительно возвращать синтаксически корректный JSON", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "Сгенерировать новые резервные коды для восстановления аккаунта", "Generate New Codes": "Сгенерировать новые коды", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "Создавайте токены на странице «Токены». Их можно ограничить моделями, группами, IP и лимитами частоты.", - "Generated image": "Сгенерированное изображение", + "Generated image": "Сгенерированное изображение {{number}}", "Generating new codes will invalidate all existing backup codes.": "Генерация новых кодов аннулирует все существующие резервные коды.", "Generating...": "Создание...", "Generation quality preset": "Пресет качества генерации", @@ -4403,6 +4403,24 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "playground.image.zoom": "Zoom", + "playground.endpoint.autoInferredLabel": "Эндпоинт (автоопределение)", + "playground.endpoint.autoInferred": "Автоопределение", + "playground.image.quality": "Качество изображения", + "playground.image.size": "Размер изображения", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "Текстовые модели GPT, включая результаты Responses image_generation_call.", + "playground.endpoint.description.claude-messages": "Модели Claude Haiku, Sonnet и Opus.", + "playground.endpoint.description.image-generations": "Специализированные модели изображений, такие как gpt-image и dall-e.", + "playground.endpoint.description.chat-completions": "Устаревшие модели chat completion, совместимые с OpenAI.", + "errors.gatewayTimeout": "Превышено время ожидания шлюза. Запрос на генерацию изображения занял слишком много времени. Повторите попытку позже.", + "errors.httpError": "HTTP-ошибка {{status}} {{statusText}}", + "errors.requestError": "Произошла ошибка запроса", + "playground.image.size.formatError": "Размер должен быть в формате axb с положительными целыми значениями", + "playground.image.size.limitError": "Ширина и высота должны быть не больше 3840", + "playground.image.quality.select": "Выберите качество" } } diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 1e5fede3bf27..04f10348c68b 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Các Dự Án Liên Quan", "footer.defaultCopyright": "Bản quyền được bảo lưu.", - "footer.new\u0061pi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.", + "footer.newapi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Đối với các kênh được thêm sau ngày 10 tháng 5 năm 2025, không cần loại bỏ \".\" khỏi tên mô hình trong quá trình triển khai", "For private deployments, format: https://fastgpt.run/api/openapi": "Đối với các triển khai riêng tư, định dạng: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Buộc phản hồi JSON hợp lệ về cú pháp", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "Tạo mã dự phòng mới để khôi phục tài khoản", "Generate New Codes": "Tạo mã mới", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "Tạo token tại trang Tokens; bạn có thể giới hạn theo model, nhóm, IP và rate-limit.", - "Generated image": "Ảnh được tạo", + "Generated image": "Ảnh được tạo {{number}}", "Generating new codes will invalidate all existing backup codes.": "Tạo mã mới sẽ vô hiệu hóa tất cả các mã dự phòng hiện có.", "Generating...": "Đang tạo...", "Generation quality preset": "Mức chất lượng sinh", @@ -4403,6 +4403,24 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "playground.image.zoom": "Zoom", + "playground.endpoint.autoInferredLabel": "Điểm cuối (tự động nhận diện)", + "playground.endpoint.autoInferred": "Tự động nhận diện", + "playground.image.quality": "Chất lượng ảnh", + "playground.image.size": "Kích thước ảnh", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "Mô hình văn bản GPT, bao gồm kết quả Responses image_generation_call.", + "playground.endpoint.description.claude-messages": "Các mô hình Claude Haiku, Sonnet và Opus.", + "playground.endpoint.description.image-generations": "Các mô hình ảnh chuyên dụng như gpt-image và dall-e.", + "playground.endpoint.description.chat-completions": "Các mô hình chat completion tương thích OpenAI kiểu cũ.", + "errors.gatewayTimeout": "Hết thời gian chờ gateway. Yêu cầu tạo ảnh mất quá nhiều thời gian. Vui lòng thử lại sau.", + "errors.httpError": "Lỗi HTTP {{status}}{{statusText}}", + "errors.requestError": "Đã xảy ra lỗi yêu cầu", + "playground.image.size.formatError": "Kích thước phải dùng định dạng axb với chiều rộng và chiều cao là số nguyên dương", + "playground.image.size.limitError": "Chiều rộng và chiều cao phải nhỏ hơn hoặc bằng 3840", + "playground.image.quality.select": "Vui lòng chọn chất lượng" } } diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 5d3014ce7408..581b0d0393f7 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1752,7 +1752,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "相关项目", "footer.defaultCopyright": "版权所有。", - "footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", + "footer.newapi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"", "For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署,格式为:https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "强制返回语法合法的 JSON", @@ -1807,7 +1807,7 @@ "Generate new backup codes for account recovery": "生成新的备份代码用于账户恢复", "Generate New Codes": "生成新代码", "Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.": "在「令牌」页面生成 API Key,可以按模型、分组、IP、速率等维度精细化授权。", - "Generated image": "生成的图像", + "Generated image": "生成的图像 {{number}}", "Generating new codes will invalidate all existing backup codes.": "生成新代码将使所有现有备份代码失效。", "Generating...": "生成中...", "Generation quality preset": "生成质量预设", @@ -4403,6 +4403,21 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放" + "playground.image.zoom": "缩放", + "playground.endpoint.autoInferredLabel": "端点(自动识别)", + "playground.endpoint.autoInferred": "自动识别", + "playground.image.quality": "图片质量", + "playground.image.size": "图片大小", + "playground.endpoint.label.responses": "Responses (/v1/responses)", + "playground.endpoint.label.claude-messages": "Claude Messages (/v1/messages)", + "playground.endpoint.label.image-generations": "Images (/v1/images/generations)", + "playground.endpoint.label.chat-completions": "Chat Completions (/v1/chat/completions)", + "playground.endpoint.description.responses": "GPT 文本模型,包括 Responses image_generation_call 结果。", + "playground.endpoint.description.claude-messages": "Claude Haiku、Sonnet 和 Opus 模型。", + "playground.endpoint.description.image-generations": "gpt-image 和 dall-e 等专用图像模型。", + "playground.endpoint.description.chat-completions": "传统的 OpenAI 兼容 chat completion 模型。", + "playground.image.size.formatError": "大小必须使用 axb 格式,且宽高为正整数", + "playground.image.size.limitError": "大小的宽和高都必须小于或等于 3840", + "playground.image.quality.select": "请选择质量" } }