From 680271d69f620044c9826815c9d96283b161dd9b Mon Sep 17 00:00:00 2001 From: link87ss Date: Fri, 27 Mar 2026 22:25:27 +0800 Subject: [PATCH 001/282] feat(playground): add video size/duration/quality selectors --- dto/openai_request.go | 2 + .../components/playground/SettingsPanel.jsx | 61 +++++++++++++++++++ web/src/constants/playground.constants.js | 3 + web/src/helpers/api.js | 17 ++++++ 4 files changed, 83 insertions(+) diff --git a/dto/openai_request.go b/dto/openai_request.go index 76a866621a71..5727327d9a5d 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -46,6 +46,8 @@ type GeneralOpenAIRequest struct { Input any `json:"input,omitempty"` Instruction string `json:"instruction,omitempty"` Size string `json:"size,omitempty"` + Seconds *int `json:"seconds,omitempty"` + Quality *string `json:"quality,omitempty"` Functions json.RawMessage `json:"functions,omitempty"` FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` PresencePenalty *float64 `json:"presence_penalty,omitempty"` diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 3899e596fa6e..6bbcbc5fc0c4 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -47,6 +47,23 @@ const SettingsPanel = ({ messages, }) => { const { t } = useTranslation(); + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + const videoSizeOptions = [ + { label: '1280x720', value: '1280x720' }, + { label: '720x1280', value: '720x1280' }, + { label: '1792x1024', value: '1792x1024' }, + { label: '1024x1792', value: '1024x1792' }, + { label: '1024x1024', value: '1024x1024' }, + ]; + const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((v) => ({ + label: `${v}s`, + value: String(v), + })); + const videoQualityOptions = [ + { label: 'standard', value: 'standard' }, + { label: 'high', value: 'high' }, + ]; const currentConfig = { inputs, @@ -200,6 +217,50 @@ const SettingsPanel = ({ /> + {/* 视频参数(仅视频模型显示) */} + {isVideoModel && ( +
+
+
+ + {t('视频尺寸')} + + onInputChange('videoSeconds', value)} + disabled={customRequestMode} + /> +
+
+ + {t('视频质量')} + + onInputChange('videoSize', value)} + disabled={customRequestMode} + /> +
+
+ + {t('视频时长')} + + onInputChange('videoQuality', value)} + disabled={customRequestMode} + /> +
+
+
+ )} + {/* 流式输出开关 */}
diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index 9ba88621cbd1..3dd70cbc113d 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -76,6 +76,7 @@ export const DEBUG_TABS = { // ========== API 相关常量 ========== export const API_ENDPOINTS = { CHAT_COMPLETIONS: '/pg/chat/completions', + VIDEO_GENERATIONS: '/v1/video/generations', USER_MODELS: '/api/user/models', USER_GROUPS: '/api/user/self/groups', }; @@ -94,6 +95,9 @@ export const DEFAULT_CONFIG = { stream: true, imageEnabled: false, imageUrls: [''], + videoSize: '1280x720', + videoSeconds: '10', + videoQuality: 'standard', }, parameterEnabled: { temperature: true, diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 9381968e3711..dcb8b5334e79 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -155,6 +155,21 @@ export const buildApiPayload = ( } }); + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + if (isVideoModel) { + payload.stream = false; + if (inputs.videoSize) { + payload.size = inputs.videoSize; + } + if (inputs.videoSeconds) { + payload.seconds = String(inputs.videoSeconds); + } + if (inputs.videoQuality) { + payload.quality = inputs.videoQuality; + } + } + return payload; }; diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 8ec50cf45d92..e61bc611914d 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -40,6 +40,82 @@ export const useApiRequest = ( saveMessages, ) => { const { t } = useTranslation(); + const isVideoGenerationPayload = useCallback((payload) => { + const model = payload?.model; + return ( + typeof model === 'string' && + model.includes('video') && + (!!payload?.seconds || !!payload?.size || !!payload?.quality) + ); + }, []); + + const getTextFromMessageContent = useCallback((content) => { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + const textParts = content + .filter((item) => item?.type === 'text') + .map((item) => item?.text || '') + .filter(Boolean); + return textParts.join('\n'); + }, []); + + const getImageFromMessageContent = useCallback((content) => { + if (!Array.isArray(content)) { + return ''; + } + const imageItem = content.find((item) => item?.type === 'image_url'); + if (!imageItem) { + return ''; + } + const imageURL = imageItem.image_url; + if (typeof imageURL === 'string') { + return imageURL; + } + return imageURL?.url || ''; + }, []); + + const buildVideoRequestPayload = useCallback( + (payload) => { + const messages = Array.isArray(payload?.messages) ? payload.messages : []; + const lastUserMessage = [...messages] + .reverse() + .find((m) => m?.role === 'user'); + const prompt = getTextFromMessageContent(lastUserMessage?.content); + const image = getImageFromMessageContent(lastUserMessage?.content); + + return { + model: payload.model, + prompt, + seconds: payload.seconds, + size: payload.size, + quality: payload.quality, + ...(image ? { image } : {}), + }; + }, + [getImageFromMessageContent, getTextFromMessageContent], + ); + + const resolveEndpointAndPayload = useCallback( + (payload) => { + if (isVideoGenerationPayload(payload)) { + return { + endpoint: API_ENDPOINTS.VIDEO_GENERATIONS, + requestPayload: buildVideoRequestPayload(payload), + forceNonStream: true, + }; + } + return { + endpoint: API_ENDPOINTS.CHAT_COMPLETIONS, + requestPayload: payload, + forceNonStream: false, + }; + }, + [buildVideoRequestPayload, isVideoGenerationPayload], + ); // 处理消息自动关闭逻辑的公共函数 const applyAutoCollapseLogic = useCallback( @@ -174,9 +250,10 @@ export const useApiRequest = ( // 非流式请求 const handleNonStreamRequest = useCallback( async (payload) => { + const { endpoint, requestPayload } = resolveEndpointAndPayload(payload); setDebugData((prev) => ({ ...prev, - request: payload, + request: requestPayload, timestamp: new Date().toISOString(), response: null, sseMessages: null, // 非流式请求清除 SSE 消息 @@ -185,13 +262,13 @@ export const useApiRequest = ( setActiveDebugTab(DEBUG_TABS.REQUEST); try { - const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'New-Api-User': getUserIdFromLocalStorage(), }, - body: JSON.stringify(payload), + body: JSON.stringify(requestPayload), }); if (!response.ok) { @@ -228,6 +305,38 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.RESPONSE); + if ( + endpoint === API_ENDPOINTS.VIDEO_GENERATIONS || + data.object === 'video' || + data.task_id + ) { + const summary = [ + `${t('视频任务已创建')}`, + `task_id: ${data.task_id || data.id || '-'}`, + `status: ${data.status || '-'}`, + `seconds: ${data.seconds || requestPayload.seconds || '-'}`, + `size: ${data.size || requestPayload.size || '-'}`, + ].join('\n'); + 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: summary, + status: MESSAGE_STATUS.COMPLETE, + ...autoCollapseState, + }; + } + return newMessages; + }); + return; + } + if (data.choices?.[0]) { const choice = data.choices[0]; let content = choice.message?.content || ''; @@ -285,7 +394,14 @@ export const useApiRequest = ( }); } }, - [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic], + [ + resolveEndpointAndPayload, + setDebugData, + setActiveDebugTab, + setMessage, + t, + applyAutoCollapseLogic, + ], ); // SSE请求 @@ -500,13 +616,14 @@ export const useApiRequest = ( // 发送请求 const sendRequest = useCallback( (payload, isStream) => { - if (isStream) { + const { forceNonStream } = resolveEndpointAndPayload(payload); + if (isStream && !forceNonStream) { handleSSE(payload); } else { handleNonStreamRequest(payload); } }, - [handleSSE, handleNonStreamRequest], + [resolveEndpointAndPayload, handleSSE, handleNonStreamRequest], ); return { From 665855612660740c24efb688ecf4036d88af9760 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 12:02:22 +0800 Subject: [PATCH 003/282] fix(playground): always route video models to video endpoint --- web/src/hooks/playground/useApiRequest.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index e61bc611914d..405eb3234207 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -42,11 +42,7 @@ export const useApiRequest = ( const { t } = useTranslation(); const isVideoGenerationPayload = useCallback((payload) => { const model = payload?.model; - return ( - typeof model === 'string' && - model.includes('video') && - (!!payload?.seconds || !!payload?.size || !!payload?.quality) - ); + return typeof model === 'string' && model.includes('video'); }, []); const getTextFromMessageContent = useCallback((content) => { From 90063cbc0a82341512b2fa386903b3f867dd2c8b Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 12:54:53 +0800 Subject: [PATCH 004/282] fix(playground): use user-authenticated pg video generation endpoint --- controller/playground.go | 57 ++++++-- dto/openai_request.go | 2 + router/relay-router.go | 2 + .../components/playground/SettingsPanel.jsx | 61 +++++++++ web/src/constants/playground.constants.js | 4 + web/src/helpers/api.js | 15 +++ web/src/hooks/playground/useApiRequest.jsx | 125 +++++++++++++++++- 7 files changed, 251 insertions(+), 15 deletions(-) diff --git a/controller/playground.go b/controller/playground.go index 501c4e156573..9e015b26d5d9 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -35,22 +35,61 @@ func Playground(c *gin.Context) { return } - userId := c.GetInt("id") + if newAPIError = setupPlaygroundTokenContext(c, fmt.Sprintf("playground-%s", relayInfo.UsingGroup), relayInfo.UsingGroup); newAPIError != nil { + return + } + + Relay(c, types.RelayFormatOpenAI) +} - // Write user context to ensure acceptUnsetRatio is available +func PlaygroundVideoSubmit(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video", c.GetString("group")); newAPIError != nil { + return + } + RelayTask(c) +} + +func PlaygroundVideoFetch(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video-fetch", c.GetString("group")); newAPIError != nil { + return + } + RelayTaskFetch(c) +} + +func setupPlaygroundTokenContext(c *gin.Context, tokenName string, tokenGroup string) *types.NewAPIError { + userId := c.GetInt("id") userCache, err := model.GetUserCache(userId) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) - return + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } userCache.WriteContext(c) - + if tokenGroup == "" { + tokenGroup = c.GetString("group") + } + if tokenGroup == "" { + tokenGroup = userCache.Group + } tempToken := &model.Token{ UserId: userId, - Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup), - Group: relayInfo.UsingGroup, + Name: tokenName, + Group: tokenGroup, } _ = middleware.SetupContextForToken(c, tempToken) - - Relay(c, types.RelayFormatOpenAI) + return nil } diff --git a/dto/openai_request.go b/dto/openai_request.go index 76a866621a71..02736b49a56f 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -46,6 +46,8 @@ type GeneralOpenAIRequest struct { Input any `json:"input,omitempty"` Instruction string `json:"instruction,omitempty"` Size string `json:"size,omitempty"` + Seconds *string `json:"seconds,omitempty"` + Quality *string `json:"quality,omitempty"` Functions json.RawMessage `json:"functions,omitempty"` FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` PresencePenalty *float64 `json:"presence_penalty,omitempty"` diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..830288077e6e 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -65,6 +65,8 @@ func SetRelayRouter(router *gin.Engine) { playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) { playgroundRouter.POST("/chat/completions", controller.Playground) + playgroundRouter.POST("/video/generations", controller.PlaygroundVideoSubmit) + playgroundRouter.GET("/video/generations/:task_id", controller.PlaygroundVideoFetch) } relayV1Router := router.Group("/v1") relayV1Router.Use(middleware.RouteTag("relay")) diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 3899e596fa6e..6bbcbc5fc0c4 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -47,6 +47,23 @@ const SettingsPanel = ({ messages, }) => { const { t } = useTranslation(); + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + const videoSizeOptions = [ + { label: '1280x720', value: '1280x720' }, + { label: '720x1280', value: '720x1280' }, + { label: '1792x1024', value: '1792x1024' }, + { label: '1024x1792', value: '1024x1792' }, + { label: '1024x1024', value: '1024x1024' }, + ]; + const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((v) => ({ + label: `${v}s`, + value: String(v), + })); + const videoQualityOptions = [ + { label: 'standard', value: 'standard' }, + { label: 'high', value: 'high' }, + ]; const currentConfig = { inputs, @@ -200,6 +217,50 @@ const SettingsPanel = ({ />
+ {/* 视频参数(仅视频模型显示) */} + {isVideoModel && ( +
+
+
+ + {t('视频尺寸')} + + onInputChange('videoSeconds', value)} + disabled={customRequestMode} + /> +
+
+ + {t('视频质量')} + + onInputChange('videoSize', value)} + disabled={customRequestMode} + /> +
+
+ + {t('视频时长')} + + onInputChange('videoQuality', value)} + disabled={customRequestMode} + /> +
+
+
+ )} + {/* 流式输出开关 */}
diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index 9ba88621cbd1..9751eb67224f 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -76,6 +76,7 @@ export const DEBUG_TABS = { // ========== API 相关常量 ========== export const API_ENDPOINTS = { CHAT_COMPLETIONS: '/pg/chat/completions', + VIDEO_GENERATIONS: '/pg/video/generations', USER_MODELS: '/api/user/models', USER_GROUPS: '/api/user/self/groups', }; @@ -94,6 +95,9 @@ export const DEFAULT_CONFIG = { stream: true, imageEnabled: false, imageUrls: [''], + videoSize: '1280x720', + videoSeconds: '10', + videoQuality: 'standard', }, parameterEnabled: { temperature: true, diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 9381968e3711..dcb8b5334e79 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -155,6 +155,21 @@ export const buildApiPayload = ( } }); + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + if (isVideoModel) { + payload.stream = false; + if (inputs.videoSize) { + payload.size = inputs.videoSize; + } + if (inputs.videoSeconds) { + payload.seconds = String(inputs.videoSeconds); + } + if (inputs.videoQuality) { + payload.quality = inputs.videoQuality; + } + } + return payload; }; diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 8ec50cf45d92..398b3b142166 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -40,6 +40,78 @@ export const useApiRequest = ( saveMessages, ) => { const { t } = useTranslation(); + const isVideoGenerationPayload = useCallback((payload) => { + const model = payload?.model; + return typeof model === 'string' && model.includes('video'); + }, []); + + const getTextFromMessageContent = useCallback((content) => { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return ''; + } + const textParts = content + .filter((item) => item?.type === 'text') + .map((item) => item?.text || '') + .filter(Boolean); + return textParts.join('\n'); + }, []); + + const getImageFromMessageContent = useCallback((content) => { + if (!Array.isArray(content)) { + return ''; + } + const imageItem = content.find((item) => item?.type === 'image_url'); + if (!imageItem) { + return ''; + } + const imageURL = imageItem.image_url; + if (typeof imageURL === 'string') { + return imageURL; + } + return imageURL?.url || ''; + }, []); + + const buildVideoRequestPayload = useCallback( + (payload) => { + const messages = Array.isArray(payload?.messages) ? payload.messages : []; + const lastUserMessage = [...messages] + .reverse() + .find((m) => m?.role === 'user'); + const prompt = getTextFromMessageContent(lastUserMessage?.content); + const image = getImageFromMessageContent(lastUserMessage?.content); + + return { + model: payload.model, + prompt, + seconds: payload.seconds, + size: payload.size, + quality: payload.quality, + ...(image ? { image } : {}), + }; + }, + [getImageFromMessageContent, getTextFromMessageContent], + ); + + const resolveEndpointAndPayload = useCallback( + (payload) => { + if (isVideoGenerationPayload(payload)) { + return { + endpoint: API_ENDPOINTS.VIDEO_GENERATIONS, + requestPayload: buildVideoRequestPayload(payload), + forceNonStream: true, + }; + } + return { + endpoint: API_ENDPOINTS.CHAT_COMPLETIONS, + requestPayload: payload, + forceNonStream: false, + }; + }, + [buildVideoRequestPayload, isVideoGenerationPayload], + ); // 处理消息自动关闭逻辑的公共函数 const applyAutoCollapseLogic = useCallback( @@ -174,9 +246,10 @@ export const useApiRequest = ( // 非流式请求 const handleNonStreamRequest = useCallback( async (payload) => { + const { endpoint, requestPayload } = resolveEndpointAndPayload(payload); setDebugData((prev) => ({ ...prev, - request: payload, + request: requestPayload, timestamp: new Date().toISOString(), response: null, sseMessages: null, // 非流式请求清除 SSE 消息 @@ -185,13 +258,13 @@ export const useApiRequest = ( setActiveDebugTab(DEBUG_TABS.REQUEST); try { - const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'New-Api-User': getUserIdFromLocalStorage(), }, - body: JSON.stringify(payload), + body: JSON.stringify(requestPayload), }); if (!response.ok) { @@ -228,6 +301,44 @@ export const useApiRequest = ( })); setActiveDebugTab(DEBUG_TABS.RESPONSE); + if ( + endpoint === API_ENDPOINTS.VIDEO_GENERATIONS || + data.object === 'video' || + data.task_id + ) { + const taskId = data.task_id || data.id || ''; + const fallbackVideoURL = taskId + ? `${window.location.origin}/v1/videos/${taskId}/content` + : ''; + const videoURL = data.url || data.video_url || fallbackVideoURL; + const summary = [ + `${t('视频任务已创建')}`, + `task_id: ${taskId || '-'}`, + `status: ${data.status || '-'}`, + `seconds: ${data.seconds || requestPayload.seconds || '-'}`, + `size: ${data.size || requestPayload.size || '-'}`, + ...(videoURL ? [`video_url: ${videoURL}`] : []), + ].join('\n'); + 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: summary, + status: MESSAGE_STATUS.COMPLETE, + ...autoCollapseState, + }; + } + return newMessages; + }); + return; + } + if (data.choices?.[0]) { const choice = data.choices[0]; let content = choice.message?.content || ''; @@ -285,7 +396,14 @@ export const useApiRequest = ( }); } }, - [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic], + [ + resolveEndpointAndPayload, + setDebugData, + setActiveDebugTab, + setMessage, + t, + applyAutoCollapseLogic, + ], ); // SSE请求 @@ -500,13 +618,14 @@ export const useApiRequest = ( // 发送请求 const sendRequest = useCallback( (payload, isStream) => { - if (isStream) { + const { forceNonStream } = resolveEndpointAndPayload(payload); + if (isStream && !forceNonStream) { handleSSE(payload); } else { handleNonStreamRequest(payload); } }, - [handleSSE, handleNonStreamRequest], + [resolveEndpointAndPayload, handleSSE, handleNonStreamRequest], ); return { From 629be09f3b527afa8e5c2b29656d315c9b259b96 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 14:58:36 +0800 Subject: [PATCH 006/282] Revert "Add playground video generation endpoints and UI; wire video parameters through frontend and backend" --- web/src/hooks/playground/useApiRequest.jsx | 40 ++++------------------ 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index a28b0e9be978..405eb3234207 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -306,40 +306,12 @@ export const useApiRequest = ( data.object === 'video' || data.task_id ) { -if ( - endpoint === API_ENDPOINTS.VIDEO_GENERATIONS || - data.object === 'video' || - data.task_id -) { - const taskId = data.task_id || data.id || ''; - const fallbackVideoURL = taskId - ? `${window.location.origin}/v1/videos/${taskId}/content` - : ''; - const videoURL = data.url || data.video_url || fallbackVideoURL; - const summary = [ - `${t('视频任务已创建')}`, - `task_id: ${taskId || '-'}`, - `status: ${data.status || '-'}`, - `seconds: ${data.seconds || requestPayload.seconds || '-'}`, - `size: ${data.size || requestPayload.size || '-'}`, - ...(videoURL ? [`video_url: ${videoURL}`] : []), - ].join('\n'); - 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: summary, - status: MESSAGE_STATUS.COMPLETE, - ...autoCollapseState, - }; - } - return newMessages; - }); - return; -} + const summary = [ + `${t('视频任务已创建')}`, + `task_id: ${data.task_id || data.id || '-'}`, + `status: ${data.status || '-'}`, + `seconds: ${data.seconds || requestPayload.seconds || '-'}`, + `size: ${data.size || requestPayload.size || '-'}`, ].join('\n'); setMessage((prevMessage) => { const newMessages = [...prevMessage]; From 8e19212a84bf087f80d5165fd87374e94ea5e9d6 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 15:38:54 +0800 Subject: [PATCH 007/282] fix grok video url response --- controller/relay.go | 37 ++++++++++++++++++++++ relay/channel/task/sora/adaptor.go | 31 ++++++++++++++++++ web/src/hooks/playground/useApiRequest.jsx | 26 +++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/controller/relay.go b/controller/relay.go index 10dfd502fbd0..030cecf00c7d 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/relay" + taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" @@ -586,6 +587,42 @@ func RelayTask(c *gin.Context) { task.Quota = result.Quota task.Data = result.TaskData task.Action = relayInfo.Action + if adaptor := relay.GetTaskAdaptor(result.Platform); adaptor != nil && len(result.TaskData) > 0 { + if taskInfo, err := adaptor.ParseTaskResult(result.TaskData); err == nil && taskInfo != nil && taskInfo.Status != "" { + now := time.Now().Unix() + task.Status = model.TaskStatus(taskInfo.Status) + switch task.Status { + case model.TaskStatusSubmitted: + task.Progress = taskcommon.ProgressSubmitted + case model.TaskStatusQueued: + task.Progress = taskcommon.ProgressQueued + case model.TaskStatusInProgress: + task.Progress = taskcommon.ProgressInProgress + if task.StartTime == 0 { + task.StartTime = now + } + case model.TaskStatusSuccess: + task.Progress = taskcommon.ProgressComplete + if task.StartTime == 0 { + task.StartTime = now + } + if task.FinishTime == 0 { + task.FinishTime = now + } + task.PrivateData.ResultURL = taskInfo.Url + case model.TaskStatusFailure: + task.Progress = taskcommon.ProgressComplete + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskInfo.Reason + task.PrivateData.ResultURL = taskInfo.Url + } + if taskInfo.Progress != "" { + task.Progress = taskInfo.Progress + } + } + } if insertErr := task.Insert(); insertErr != nil { common.SysError("insert task error: " + insertErr.Error()) } diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index e9029aa20d46..9cc6bf49a109 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -21,6 +21,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pkg/errors" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -44,6 +45,7 @@ type responseTask struct { Object string `json:"object"` Model string `json:"model"` Status string `json:"status"` + URL string `json:"url,omitempty"` Progress int `json:"progress"` CreatedAt int64 `json:"created_at"` CompletedAt int64 `json:"completed_at,omitempty"` @@ -68,6 +70,23 @@ type TaskAdaptor struct { baseURL string } +func extractVideoURL(respBody []byte) string { + for _, path := range []string{ + "url", + "video_url", + "metadata.url", + "data.url", + "data.video_url", + "output.video_url", + "task_result.videos.0.url", + } { + if url := strings.TrimSpace(gjson.GetBytes(respBody, path).String()); url != "" { + return url + } + } + return "" +} + func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl @@ -248,6 +267,9 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) return } + if dResp.URL == "" { + dResp.URL = extractVideoURL(responseBody) + } // 使用公开 task_xxxx ID 返回给客户端 dResp.ID = info.PublicTaskID @@ -292,6 +314,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e if err := common.Unmarshal(respBody, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") } + if resTask.URL == "" { + resTask.URL = extractVideoURL(respBody) + } taskResult := relaycommon.TaskInfo{ Code: 0, @@ -304,6 +329,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Status = model.TaskStatusInProgress case "completed": taskResult.Status = model.TaskStatusSuccess + taskResult.Url = resTask.URL // Url intentionally left empty — the caller constructs the proxy URL using the public task ID case "failed", "cancelled": taskResult.Status = model.TaskStatusFailure @@ -327,5 +353,10 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { if data, err = sjson.SetBytes(data, "id", task.TaskID); err != nil { return nil, errors.Wrap(err, "set id failed") } + if gjson.GetBytes(data, "task_id").Exists() { + if data, err = sjson.SetBytes(data, "task_id", task.TaskID); err != nil { + return nil, errors.Wrap(err, "set task_id failed") + } + } return data, nil } diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 405eb3234207..21efc10aca5a 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -95,6 +95,29 @@ export const useApiRequest = ( [getImageFromMessageContent, getTextFromMessageContent], ); + const extractVideoUrl = useCallback((payload) => { + if (!payload || typeof payload !== 'object') { + return ''; + } + + const candidates = [ + payload.url, + payload.video_url, + payload.result_url, + payload.metadata?.url, + payload.data?.url, + payload.data?.video_url, + payload.data?.result_url, + payload.data?.metadata?.url, + ]; + + const matched = candidates.find( + (item) => typeof item === 'string' && item.trim() !== '', + ); + + return matched?.trim() || ''; + }, []); + const resolveEndpointAndPayload = useCallback( (payload) => { if (isVideoGenerationPayload(payload)) { @@ -306,12 +329,14 @@ export const useApiRequest = ( data.object === 'video' || data.task_id ) { + const videoUrl = extractVideoUrl(data); const summary = [ `${t('视频任务已创建')}`, `task_id: ${data.task_id || data.id || '-'}`, `status: ${data.status || '-'}`, `seconds: ${data.seconds || requestPayload.seconds || '-'}`, `size: ${data.size || requestPayload.size || '-'}`, + ...(videoUrl ? [`url: ${videoUrl}`, `[Open Video](${videoUrl})`] : []), ].join('\n'); setMessage((prevMessage) => { const newMessages = [...prevMessage]; @@ -396,6 +421,7 @@ export const useApiRequest = ( setActiveDebugTab, setMessage, t, + extractVideoUrl, applyAutoCollapseLogic, ], ); From 2933917acda1198ceab4ef2369d0e6c148e029af Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 16:04:31 +0800 Subject: [PATCH 008/282] avoid duplicate video preview --- web/src/hooks/playground/useApiRequest.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 21efc10aca5a..deb8936dab13 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -336,7 +336,9 @@ export const useApiRequest = ( `status: ${data.status || '-'}`, `seconds: ${data.seconds || requestPayload.seconds || '-'}`, `size: ${data.size || requestPayload.size || '-'}`, - ...(videoUrl ? [`url: ${videoUrl}`, `[Open Video](${videoUrl})`] : []), + ...(videoUrl + ? [`url: \`${videoUrl}\``, `[Open Video](${videoUrl})`] + : []), ].join('\n'); setMessage((prevMessage) => { const newMessages = [...prevMessage]; From df6cb95c18b6ccd72a3518ed712cada72b946c15 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 16:21:15 +0800 Subject: [PATCH 009/282] add grok video preset options --- .../components/playground/SettingsPanel.jsx | 33 +++++++++++++++++-- web/src/constants/playground.constants.js | 3 +- web/src/helpers/api.js | 11 ++++++- web/src/hooks/playground/useApiRequest.jsx | 1 + 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 6bbcbc5fc0c4..bca3945ec660 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -49,6 +49,7 @@ const SettingsPanel = ({ const { t } = useTranslation(); const isVideoModel = typeof inputs.model === 'string' && inputs.model.includes('video'); + const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; const videoSizeOptions = [ { label: '1280x720', value: '1280x720' }, { label: '720x1280', value: '720x1280' }, @@ -60,9 +61,15 @@ const SettingsPanel = ({ label: `${v}s`, value: String(v), })); + const videoPresetOptions = [ + { label: 'Normal', value: 'normal' }, + { label: 'Fun', value: 'fun' }, + { label: 'Spicy', value: 'spicy' }, + { label: 'Custom', value: 'custom' }, + ]; const videoQualityOptions = [ - { label: 'standard', value: 'standard' }, - { label: 'high', value: 'high' }, + { label: '480p', value: '480p' }, + { label: '720p', value: '720p' }, ]; const currentConfig = { @@ -245,6 +252,20 @@ const SettingsPanel = ({ disabled={customRequestMode} />
+ {isGrokImagineVideoModel && ( +
+ + {t('椋庢牸棰勮')} + + onInputChange('videoQuality', value)} disabled={customRequestMode} /> diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index 9751eb67224f..1b8b142530bc 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -97,7 +97,8 @@ export const DEFAULT_CONFIG = { imageUrls: [''], videoSize: '1280x720', videoSeconds: '10', - videoQuality: 'standard', + videoQuality: '480p', + videoPreset: 'normal', }, parameterEnabled: { temperature: true, diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index dcb8b5334e79..1d0a9e2e71de 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -157,6 +157,7 @@ export const buildApiPayload = ( const isVideoModel = typeof inputs.model === 'string' && inputs.model.includes('video'); + const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; if (isVideoModel) { payload.stream = false; if (inputs.videoSize) { @@ -166,7 +167,15 @@ export const buildApiPayload = ( payload.seconds = String(inputs.videoSeconds); } if (inputs.videoQuality) { - payload.quality = inputs.videoQuality; + payload.quality = + inputs.videoQuality === '720p' + ? 'high' + : inputs.videoQuality === '480p' + ? 'standard' + : inputs.videoQuality; + } + if (isGrokImagineVideoModel && inputs.videoPreset) { + payload.preset = inputs.videoPreset; } } diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index deb8936dab13..a65c6f40ff3e 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -89,6 +89,7 @@ export const useApiRequest = ( seconds: payload.seconds, size: payload.size, quality: payload.quality, + preset: payload.preset, ...(image ? { image } : {}), }; }, From 37f727c1a61266300fca51298bc436a40c03b9c1 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 16:46:38 +0800 Subject: [PATCH 010/282] fix grok video quality request handling --- .../components/playground/SettingsPanel.jsx | 2 +- web/src/hooks/playground/useApiRequest.jsx | 42 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index bca3945ec660..1ece90f888f1 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -255,7 +255,7 @@ const SettingsPanel = ({ {isGrokImagineVideoModel && (
- {t('椋庢牸棰勮')} + {t('风格预设')} onInputChange('imageSize', value)} + disabled={customRequestMode} + /> +
+
+ )} + {isVideoModel && (
diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index 1361919fcedb..d5533c6a384e 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -97,6 +97,7 @@ export const DEFAULT_CONFIG = { stream: true, imageEnabled: false, imageUrls: [''], + imageSize: '1024x1024', videoSize: '1280x720', videoSeconds: '10', videoQuality: '480p', diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 2279debddb07..4b30d8a41ec4 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -166,6 +166,9 @@ export const buildApiPayload = ( const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; if (isGrokImagineImageModel) { payload.stream = false; + if (inputs.imageSize) { + payload.size = inputs.imageSize; + } } if (isVideoModel) { payload.stream = false; diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index d02639bc3c04..33f225233755 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -177,12 +177,14 @@ export const useApiRequest = ( (isGrokImagineImageEditModel(payload.model) ? 'Edit the provided media.' : ''); + const size = payload?.size || payload?.imageSize; const requestPayload = { model: payload.model, group: payload.group, prompt: resolvedPrompt, n: 1, response_format: 'url', + ...(size ? { size } : {}), }; if (isGrokImagineImageEditModel(payload.model) && image) { @@ -501,6 +503,7 @@ export const useApiRequest = ( : t('图片任务已完成'), `model: ${requestPayload.model || payload?.model || '-'}`, `count: ${imageUrls.length || data.data?.length || 0}`, + `size: ${requestPayload.size || '-'}`, ]; imageUrls.forEach((url, index) => { From 2c76490aa58e466032afcdc7c94043fc64725512 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 18:57:16 +0800 Subject: [PATCH 017/282] Expand playground image size options --- web/src/components/playground/SettingsPanel.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 45c0a6eb26f2..53b8a63ce5e3 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -57,9 +57,11 @@ const SettingsPanel = ({ typeof inputs.model === 'string' && inputs.model.includes('video'); const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; const imageSizeOptions = [ - { label: '1024x1024', value: '1024x1024' }, - { label: '1536x1024', value: '1536x1024' }, - { label: '1024x1536', value: '1024x1536' }, + { label: '1:1 方图', value: '1024x1024' }, + { label: '3:2 横图', value: '1536x1024' }, + { label: '2:3 竖图', value: '1024x1536' }, + { label: '16:9 宽屏', value: '1792x1024' }, + { label: '9:16 竖屏', value: '1024x1792' }, ]; const videoSizeOptions = [ { label: '1280x720', value: '1280x720' }, From 46909d2790f75b0cb68b32cb8b9e3ae7c66341a5 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 19:07:00 +0800 Subject: [PATCH 018/282] Pass image size to xai upstream --- relay/channel/xai/adaptor.go | 1 + relay/channel/xai/adaptor_test.go | 22 ++++++++++++++++++++++ relay/channel/xai/dto.go | 3 +-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 8a4e48e86a1d..05cbb8814c36 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -43,6 +43,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf Prompt: request.Prompt, N: int(lo.FromPtrOr(request.N, uint(1))), Image: request.Image, + Size: request.Size, ResponseFormat: request.ResponseFormat, } return xaiRequest, nil diff --git a/relay/channel/xai/adaptor_test.go b/relay/channel/xai/adaptor_test.go index 2ada3ec48a72..33d08a7f4f5b 100644 --- a/relay/channel/xai/adaptor_test.go +++ b/relay/channel/xai/adaptor_test.go @@ -45,6 +45,28 @@ func TestConvertImageRequestPreservesEditImage(t *testing.T) { } } +func TestConvertImageRequestPreservesSize(t *testing.T) { + adaptor := &Adaptor{} + + converted, err := adaptor.ConvertImageRequest(nil, nil, dto.ImageRequest{ + Model: "grok-imagine-1.0", + Prompt: "draw a city skyline", + Size: "1536x1024", + ResponseFormat: "url", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + xaiReq, ok := converted.(ImageRequest) + if !ok { + t.Fatalf("expected xai.ImageRequest, got %T", converted) + } + if xaiReq.Size != "1536x1024" { + t.Fatalf("unexpected size: %s", xaiReq.Size) + } +} + func TestModelListIncludesGrokImagineOnePointZeroVariants(t *testing.T) { expected := []string{ "grok-imagine-1.0", diff --git a/relay/channel/xai/dto.go b/relay/channel/xai/dto.go index 617d32f290ce..d7dcfd92daa1 100644 --- a/relay/channel/xai/dto.go +++ b/relay/channel/xai/dto.go @@ -13,13 +13,12 @@ type ChatCompletionResponse struct { SystemFingerprint string `json:"system_fingerprint"` } -// quality, size or style are not supported by xAI API at the moment. type ImageRequest struct { Model string `json:"model"` Prompt string `json:"prompt" binding:"required"` N int `json:"n,omitempty"` Image any `json:"image,omitempty"` - // Size string `json:"size,omitempty"` + Size string `json:"size,omitempty"` // Quality string `json:"quality,omitempty"` ResponseFormat string `json:"response_format,omitempty"` // Style string `json:"style,omitempty"` From 69359747e7623d54706c3971fc43efe8c8f660e9 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sat, 28 Mar 2026 19:22:05 +0800 Subject: [PATCH 019/282] Fix grok image size options --- .../components/playground/SettingsPanel.jsx | 21 +++++++++++++------ web/src/helpers/api.js | 11 +++++++++- web/src/hooks/playground/useApiRequest.jsx | 11 +++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 53b8a63ce5e3..94f9270d1f30 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -47,6 +47,15 @@ const SettingsPanel = ({ messages, }) => { const { t } = useTranslation(); + const normalizeGrokImageSize = (size) => { + if (size === '1536x1024') { + return '1792x1024'; + } + if (size === '1024x1536') { + return '1024x1792'; + } + return size; + }; const grokImagineImageModels = new Set([ 'grok-imagine-1.0', 'grok-imagine-1.0-fast', @@ -57,11 +66,11 @@ const SettingsPanel = ({ typeof inputs.model === 'string' && inputs.model.includes('video'); const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; const imageSizeOptions = [ - { label: '1:1 方图', value: '1024x1024' }, - { label: '3:2 横图', value: '1536x1024' }, - { label: '2:3 竖图', value: '1024x1536' }, - { label: '16:9 宽屏', value: '1792x1024' }, - { label: '9:16 竖屏', value: '1024x1792' }, + { label: '1:1 方图 (1024x1024)', value: '1024x1024' }, + { label: '3:2 横图 (1792x1024)', value: '1792x1024' }, + { label: '2:3 竖图 (1024x1792)', value: '1024x1792' }, + { label: '16:9 宽屏 (1280x720)', value: '1280x720' }, + { label: '9:16 竖屏 (720x1280)', value: '720x1280' }, ]; const videoSizeOptions = [ { label: '1280x720', value: '1280x720' }, @@ -247,7 +256,7 @@ const SettingsPanel = ({ onInputChange('aspectRatio', value)} + disabled={customRequestMode} + /> +
+
+ + Output Resolution + + onInputChange('videoDuration', value)} + disabled={customRequestMode} + /> +
+
+ + Aspect Ratio + + onInputChange('videoResolution', value)} + disabled={customRequestMode} + /> +
+ )} + {inputs.model === 'veo31' && ( +
+ + Reference Mode + + onInputChange('aspectRatio', value)} disabled={customRequestMode} />
+ {(inputs.aspectRatio || 'auto') === 'auto' && ( +
+ + Auto Size + + setCustomSeconds(value.replace(/[^\d]/g, ''))} + style={{ width: 160 }} + /> + +
+ + {sortedEntries.length === 0 ? ( +
{t('暂未添加任何时长价格')}
+ ) : ( +
+ {sortedEntries.map(([seconds, price]) => ( +
+
+ {seconds} + {t('秒')} +
+ onChange(seconds, value)} + /> +
+ ))} +
+ )} + + ); +}; + export default function ModelPricingEditor({ options, refresh, @@ -101,6 +193,7 @@ export default function ModelPricingEditor({ const [addVisible, setAddVisible] = useState(false); const [batchVisible, setBatchVisible] = useState(false); const [newModelName, setNewModelName] = useState(''); + const [customDurationSeconds, setCustomDurationSeconds] = useState(''); const { selectedModel, @@ -122,6 +215,9 @@ export default function ModelPricingEditor({ isOptionalFieldEnabled, handleOptionalFieldToggle, handleNumericFieldChange, + handleDurationPriceChange, + addDurationPrice, + removeDurationPrice, handleBillingModeChange, handleSubmit, addModel, @@ -175,10 +271,20 @@ export default function ModelPricingEditor({ dataIndex: 'billingMode', key: 'billingMode', render: (_, record) => ( - + {record.billingMode === 'per-request' ? t('按次计费') - : t('按量计费')} + : record.billingMode === 'per-duration' + ? t('按时长计费') + : t('按量计费')} ), }, @@ -356,7 +462,9 @@ export default function ModelPricingEditor({ {selectedModel.billingMode === 'per-request' ? t('按次计费') - : t('按量计费')} + : selectedModel.billingMode === 'per-duration' + ? t('按时长计费') + : t('按量计费')} ) : null } @@ -381,6 +489,7 @@ export default function ModelPricingEditor({ > {t('按量计费')} {t('按次计费')} + {t('按时长计费')}
{t( @@ -415,6 +524,16 @@ export default function ModelPricingEditor({ onChange={(value) => handleNumericFieldChange('fixedPrice', value)} extraText={t('适合 MJ / 任务类等按次收费模型。')} /> + ) : selectedModel.billingMode === 'per-duration' ? ( + ) : ( <> { return formatted === '' ? null : Number(formatted); }; +const normalizeDurationPrices = (rawValue) => { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {}; + } + return Object.entries(rawValue).reduce((acc, [seconds, price]) => { + const normalizedSeconds = String(seconds).trim(); + const normalizedPrice = toNumericString(price); + if (!normalizedSeconds || normalizedPrice === '') { + return acc; + } + acc[normalizedSeconds] = normalizedPrice; + return acc; + }, {}); +}; + const parseOptionJSON = (rawValue) => { if (!rawValue || rawValue.trim() === '') { return {}; @@ -111,6 +127,9 @@ const buildModelState = (name, sourceMaps) => { sourceMaps.AudioCompletionRatio[name], ); const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]); + const durationPrices = normalizeDurationPrices( + sourceMaps.ModelPriceBySeconds[name], + ); const inputPrice = ratioToBasePrice(modelRatio); const inputPriceNumber = toNumberOrNull(inputPrice); const audioInputPrice = @@ -121,8 +140,14 @@ const buildModelState = (name, sourceMaps) => { return { ...EMPTY_MODEL, name, - billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token', + billingMode: + Object.keys(durationPrices).length > 0 + ? 'per-duration' + : hasValue(fixedPrice) + ? 'per-request' + : 'per-token', fixedPrice, + durationPrices, inputPrice, completionRatioLocked: completionRatioMeta.locked, lockedCompletionRatio: completionRatioMeta.ratio, @@ -169,21 +194,34 @@ const buildModelState = (name, sourceMaps) => { audioCompletionRatio, }, hasConflict: - hasValue(fixedPrice) && - [ - modelRatio, - completionRatio, - cacheRatio, - createCacheRatio, - imageRatio, - audioRatio, - audioCompletionRatio, - ].some(hasValue), + (hasValue(fixedPrice) && Object.keys(durationPrices).length > 0) || + (Object.keys(durationPrices).length > 0 && + [ + modelRatio, + completionRatio, + cacheRatio, + createCacheRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + ].some(hasValue)) || + (hasValue(fixedPrice) && + [ + modelRatio, + completionRatio, + cacheRatio, + createCacheRatio, + imageRatio, + audioRatio, + audioCompletionRatio, + ].some(hasValue)), }; }; export const isBasePricingUnset = (model) => - !hasValue(model.fixedPrice) && !hasValue(model.inputPrice); + !hasValue(model.fixedPrice) && + !hasValue(model.inputPrice) && + Object.keys(model.durationPrices || {}).length === 0; export const getModelWarnings = (model, t) => { if (!model) { @@ -206,6 +244,13 @@ export const getModelWarnings = (model, t) => { ); } + if ( + model.billingMode === 'per-duration' && + Object.keys(model.durationPrices || {}).length === 0 + ) { + warnings.push(t('按时长计费下至少需要填写一个秒数价格。')); + } + if ( !hasValue(model.inputPrice) && [ @@ -248,6 +293,13 @@ export const buildSummaryText = (model, t) => { return `${t('按次')} $${model.fixedPrice} / ${t('次')}`; } + if (model.billingMode === 'per-duration') { + const durationCount = Object.keys(model.durationPrices || {}).length; + return durationCount > 0 + ? `${t('按时长')} ${durationCount}${t('档价格')}` + : t('按时长计费未设置'); + } + if (hasValue(model.inputPrice)) { const extraCount = [ model.completionPrice, @@ -278,6 +330,7 @@ export const buildOptionalFieldToggles = (model) => ({ const serializeModel = (model, t) => { const result = { ModelPrice: null, + ModelPriceBySeconds: null, ModelRatio: null, CompletionRatio: null, CacheRatio: null, @@ -294,6 +347,22 @@ const serializeModel = (model, t) => { return result; } + if (model.billingMode === 'per-duration') { + const durationPrices = Object.entries(model.durationPrices || {}).reduce( + (acc, [seconds, price]) => { + const normalizedPrice = toNormalizedNumber(price); + if (normalizedPrice !== null) { + acc[String(seconds)] = normalizedPrice; + } + return acc; + }, + {}, + ); + result.ModelPriceBySeconds = + Object.keys(durationPrices).length > 0 ? durationPrices : {}; + return result; + } + const inputPrice = toNumberOrNull(model.inputPrice); const completionPrice = toNumberOrNull(model.completionPrice); const cachePrice = toNumberOrNull(model.cachePrice); @@ -396,6 +465,29 @@ const serializeModel = (model, t) => { export const buildPreviewRows = (model, t) => { if (!model) return []; + if (model.billingMode === 'per-duration') { + const durationValue = JSON.stringify( + Object.entries(model.durationPrices || {}).reduce( + (acc, [seconds, price]) => { + if (hasValue(price)) { + acc[seconds] = Number(price); + } + return acc; + }, + {}, + ), + null, + 2, + ); + return [ + { + key: 'ModelPriceBySeconds', + label: 'ModelPriceBySeconds', + value: durationValue === '{}' ? '-' : durationValue, + }, + ]; + } + if (model.billingMode === 'per-request') { return [ { @@ -406,6 +498,26 @@ export const buildPreviewRows = (model, t) => { ]; } + if (model.billingMode === 'per-duration') { + const durationValue = JSON.stringify( + Object.entries(model.durationPrices || {}).reduce((acc, [seconds, price]) => { + if (hasValue(price)) { + acc[seconds] = Number(price); + } + return acc; + }, {}), + null, + 2, + ); + return [ + { + key: 'ModelPriceBySeconds', + label: 'ModelPriceBySeconds', + value: durationValue === '{}' ? t('绌?) : durationValue, + }, + ]; + } + const inputPrice = toNumberOrNull(model.inputPrice); if (inputPrice === null) { return [ @@ -544,6 +656,7 @@ export function useModelPricingEditorState({ useEffect(() => { const sourceMaps = { ModelPrice: parseOptionJSON(options.ModelPrice), + ModelPriceBySeconds: parseOptionJSON(options.ModelPriceBySeconds), ModelRatio: parseOptionJSON(options.ModelRatio), CompletionRatio: parseOptionJSON(options.CompletionRatio), CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta), @@ -557,6 +670,7 @@ export function useModelPricingEditorState({ const names = new Set([ ...candidateModelNames, ...Object.keys(sourceMaps.ModelPrice), + ...Object.keys(sourceMaps.ModelPriceBySeconds), ...Object.keys(sourceMaps.ModelRatio), ...Object.keys(sourceMaps.CompletionRatio), ...Object.keys(sourceMaps.CompletionRatioMeta), @@ -774,6 +888,55 @@ export function useModelPricingEditorState({ }); }; + const handleDurationPriceChange = (seconds, value) => { + if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) { + return; + } + + const normalizedSeconds = String(seconds).trim(); + if (!normalizedSeconds) { + return; + } + + upsertModel(selectedModel.name, (model) => ({ + ...model, + durationPrices: { + ...(model.durationPrices || {}), + [normalizedSeconds]: value, + }, + })); + }; + + const addDurationPrice = (seconds = '') => { + if (!selectedModel) return; + const normalizedSeconds = String(seconds).trim(); + if (!normalizedSeconds) return; + + upsertModel(selectedModel.name, (model) => ({ + ...model, + durationPrices: { + ...(model.durationPrices || {}), + [normalizedSeconds]: + model.durationPrices?.[normalizedSeconds] ?? '', + }, + })); + }; + + const removeDurationPrice = (seconds) => { + if (!selectedModel) return; + const normalizedSeconds = String(seconds).trim(); + if (!normalizedSeconds) return; + + upsertModel(selectedModel.name, (model) => { + const nextDurationPrices = { ...(model.durationPrices || {}) }; + delete nextDurationPrices[normalizedSeconds]; + return { + ...model, + durationPrices: nextDurationPrices, + }; + }); + }; + const handleBillingModeChange = (value) => { if (!selectedModel) return; upsertModel(selectedModel.name, (model) => ({ @@ -847,6 +1010,7 @@ export function useModelPricingEditorState({ ...model, billingMode: selectedModel.billingMode, fixedPrice: selectedModel.fixedPrice, + durationPrices: { ...(selectedModel.durationPrices || {}) }, inputPrice: selectedModel.inputPrice, completionPrice: selectedModel.completionPrice, cachePrice: selectedModel.cachePrice, @@ -906,6 +1070,7 @@ export function useModelPricingEditorState({ try { const output = { ModelPrice: {}, + ModelPriceBySeconds: {}, ModelRatio: {}, CompletionRatio: {}, CacheRatio: {}, @@ -969,6 +1134,9 @@ export function useModelPricingEditorState({ isOptionalFieldEnabled, handleOptionalFieldToggle, handleNumericFieldChange, + handleDurationPriceChange, + addDurationPrice, + removeDurationPrice, handleBillingModeChange, handleSubmit, addModel, From f0c3684d6a43f5e53ed1bdf3cff1ba9c23b568cf Mon Sep 17 00:00:00 2001 From: link87ss Date: Sun, 29 Mar 2026 11:28:14 +0800 Subject: [PATCH 027/282] fix: remove duplicate duration pricing preview branch --- .../Ratio/hooks/useModelPricingEditorState.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js b/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js index e06e629e5fa4..54290feeb57c 100644 --- a/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js +++ b/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js @@ -498,26 +498,6 @@ export const buildPreviewRows = (model, t) => { ]; } - if (model.billingMode === 'per-duration') { - const durationValue = JSON.stringify( - Object.entries(model.durationPrices || {}).reduce((acc, [seconds, price]) => { - if (hasValue(price)) { - acc[seconds] = Number(price); - } - return acc; - }, {}), - null, - 2, - ); - return [ - { - key: 'ModelPriceBySeconds', - label: 'ModelPriceBySeconds', - value: durationValue === '{}' ? t('绌?) : durationValue, - }, - ]; - } - const inputPrice = toNumberOrNull(model.inputPrice); if (inputPrice === null) { return [ From 933834643146ccd2addced2163007ad9aa82cf1f Mon Sep 17 00:00:00 2001 From: link87ss Date: Sun, 29 Mar 2026 11:39:06 +0800 Subject: [PATCH 028/282] feat: improve duration pricing display in model details --- .../modal/components/ModelPricingTable.jsx | 143 +++++++++++------- 1 file changed, 85 insertions(+), 58 deletions(-) diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index 036c633d7129..7394762dbcfc 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -39,12 +39,29 @@ const ModelPricingTable = ({ const modelEnableGroups = Array.isArray(modelData?.enable_groups) ? modelData.enable_groups : []; + const modelPriceBySeconds = modelData?.model_price_by_seconds && typeof modelData.model_price_by_seconds === 'object' ? modelData.model_price_by_seconds : {}; + const autoChain = autoGroups.filter((g) => modelEnableGroups.includes(g)); + + const getBillingTypeLabel = (quotaType) => { + if (quotaType === 0) return t('按量计费'); + if (quotaType === 1) return t('按次计费'); + if (quotaType === 2) return t('按时长计费'); + return '-'; + }; + + const getBillingTypeColor = (quotaType) => { + if (quotaType === 0) return 'violet'; + if (quotaType === 1) return 'teal'; + if (quotaType === 2) return 'orange'; + return 'white'; + }; + const getSecondsPriceItems = (ratio) => Object.entries(modelPriceBySeconds) .map(([seconds, price]) => { @@ -57,25 +74,69 @@ const ModelPricingTable = ({ ) { return null; } + return { key: `seconds-${seconds}`, label: `${secondsValue}${t('秒')}`, value: displayPrice(priceValue * ratio), - suffix: ` / ${t('次')}`, + suffix: `/ ${t('次')}`, seconds: secondsValue, }; }) .filter(Boolean) .sort((a, b) => a.seconds - b.seconds); - const renderGroupPriceTable = () => { - // 仅展示模型可用的分组:模型 enable_groups 与用户可用分组的交集 + const renderSummaryBlock = (items, record) => { + const hasRegularItems = Array.isArray(items) && items.length > 0; + const hasSecondsItems = + Array.isArray(record.secondsPriceItems) && record.secondsPriceItems.length > 0; + + if (!hasRegularItems && !hasSecondsItems) { + return -; + } + + return ( +
+ {hasRegularItems && + items.map((item) => ( +
+
+ {item.label} {item.value} +
+
{item.suffix}
+
+ ))} + + {hasSecondsItems && ( +
+
+ {record.secondsPriceItems.map((item) => ( +
+
+ {item.label} {item.value} +
+
{item.suffix}
+
+ ))} +
+
+ )} +
+ ); + }; + + const renderGroupPriceTable = () => { const availableGroups = Object.keys(usableGroup || {}) .filter((g) => g !== '') .filter((g) => g !== 'auto') .filter((g) => modelEnableGroups.includes(g)); - // 准备表格数据 const tableData = availableGroups.map((group) => { const priceData = modelData ? calculateModelPrice({ @@ -89,26 +150,23 @@ const ModelPricingTable = ({ }) : { inputPrice: '-', outputPrice: '-', price: '-' }; - // 获取分组倍率 const groupRatioValue = groupRatio && groupRatio[group] ? groupRatio[group] : 1; + const isDurationBilling = modelData?.quota_type === 2; return { key: group, - group: group, + group, ratio: groupRatioValue, - billingType: - modelData?.quota_type === 0 - ? t('按量计费') - : modelData?.quota_type === 1 - ? t('按次计费') - : '-', - priceItems: getModelPriceItems(priceData, t, siteDisplayType), + quotaType: modelData?.quota_type, + billingType: getBillingTypeLabel(modelData?.quota_type), + priceItems: isDurationBilling + ? [] + : getModelPriceItems(priceData, t, siteDisplayType), secondsPriceItems: getSecondsPriceItems(groupRatioValue), }; }); - // 定义表格列 const columns = [ { title: t('分组'), @@ -122,7 +180,6 @@ const ModelPricingTable = ({ }, ]; - // 如果显示倍率,添加倍率列 if (showRatio) { columns.push({ title: t('倍率'), @@ -135,54 +192,24 @@ const ModelPricingTable = ({ }); } - // 添加计费类型列 columns.push({ title: t('计费类型'), dataIndex: 'billingType', - render: (text) => { - let color = 'white'; - if (text === t('按量计费')) color = 'violet'; - else if (text === t('按次计费')) color = 'teal'; - return ( - - {text || '-'} - - ); - }, + render: (text, record) => ( + + {text || '-'} + + ), }); columns.push({ title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'), dataIndex: 'priceItems', - render: (items, record) => ( -
- {items.map((item) => ( -
-
- {item.label} {item.value} -
-
{item.suffix}
-
- ))} - {record.secondsPriceItems?.length > 0 && ( -
-
- {t('按时长固定价格')} -
-
- {record.secondsPriceItems.map((item) => ( -
-
- {item.label} {item.value} -
-
{item.suffix}
-
- ))} -
-
- )} -
- ), + render: (items, record) => renderSummaryBlock(items, record), }); return ( @@ -205,11 +232,10 @@ const ModelPricingTable = ({
{t('分组价格')} -
- {t('不同用户分组的价格信息')} -
+
{t('不同用户分组的价格信息')}
+ {autoChain.length > 0 && (
{t('auto分组调用链路')} @@ -225,6 +251,7 @@ const ModelPricingTable = ({ ))}
)} + {renderGroupPriceTable()} ); From e7cde69e031a1f0e7324ba508a902e733d709337 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sun, 29 Mar 2026 11:47:31 +0800 Subject: [PATCH 029/282] fix: improve duration pricing card preview --- .../view/card/PricingCardView.jsx | 142 +++++++++++++----- 1 file changed, 105 insertions(+), 37 deletions(-) diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 477da259d7a5..f314f5e381e8 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -53,6 +53,8 @@ const CARD_STYLES = { default: 'border-gray-200 hover:border-gray-300', }; +const DURATION_PREVIEW_LIMIT = 2; + const PricingCardView = ({ filteredModels, loading, @@ -95,7 +97,6 @@ const PricingCardView = ({ rowSelection?.onChange?.(newKeys, null); }; - // 获取模型图标 const getModelIcon = (model) => { if (!model || !model.model_name) { return ( @@ -104,17 +105,15 @@ const PricingCardView = ({
); } - // 1) 优先使用模型自定义图标 + if (model.icon) { return (
-
- {getLobeHubIcon(model.icon, 32)} -
+
{getLobeHubIcon(model.icon, 32)}
); } - // 2) 退化为供应商图标 + if (model.vendor_icon) { return (
@@ -125,8 +124,6 @@ const PricingCardView = ({ ); } - // 如果没有供应商图标,使用模型名称生成头像 - const avatarText = model.model_name.slice(0, 2).toUpperCase(); return (
@@ -146,25 +143,56 @@ const PricingCardView = ({ ); }; - // 获取模型描述 - const getModelDescription = (record) => { - return record.description || ''; + const getModelDescription = (record) => record.description || ''; + + const getDurationPriceItems = (record, ratio = 1) => { + const durationPriceMap = + record?.model_price_by_seconds && + typeof record.model_price_by_seconds === 'object' + ? record.model_price_by_seconds + : {}; + + return Object.entries(durationPriceMap) + .map(([seconds, price]) => { + const secondsValue = Number(seconds); + const priceValue = Number(price); + if ( + !Number.isFinite(secondsValue) || + secondsValue <= 0 || + !Number.isFinite(priceValue) + ) { + return null; + } + + return { + key: `duration-${seconds}`, + seconds: secondsValue, + value: displayPrice(priceValue * ratio), + }; + }) + .filter(Boolean) + .sort((a, b) => a.seconds - b.seconds); }; - // 渲染标签 const renderTags = (record) => { - // 计费类型标签(左边) let billingTag = ( - ); + if (record.quota_type === 1) { billingTag = ( {t('按次计费')} ); + } else if (record.quota_type === 2) { + billingTag = ( + + {t('按时长计费')} + + ); } else if (record.quota_type === 0) { billingTag = ( @@ -173,7 +201,6 @@ const PricingCardView = ({ ); } - // 自定义标签(右边) const customTags = []; if (record.tags) { const tagArr = record.tags.split(',').filter(Boolean); @@ -192,16 +219,16 @@ const PricingCardView = ({ } return ( -
-
{billingTag}
-
+
+
{billingTag}
+
{customTags.length > 0 && renderLimitedItems({ items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag, })), - renderItem: (item, idx) => item.element, + renderItem: (item) => item.element, maxDisplay: 3, })}
@@ -209,7 +236,51 @@ const PricingCardView = ({ ); }; - // 显示骨架屏 + const renderPriceSummary = (record, priceData) => { + if (record.quota_type === 2) { + const durationItems = getDurationPriceItems( + record, + priceData?.usedGroupRatio ?? 1, + ); + + if (durationItems.length === 0) { + return ( + + - + + ); + } + + const previewItems = durationItems.slice(0, DURATION_PREVIEW_LIMIT); + const remainingCount = durationItems.length - previewItems.length; + + return ( + <> + {previewItems.map((item) => ( + + {item.seconds} + {t('秒')} {item.value} / {t('次')} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} {t('档时长价格')} + + )} + + ); + } + + return formatPriceInfo(priceData, t, siteDisplayType); + }; + if (showSkeleton) { return ( { const modelKey = getModelKey(model); const isSelected = selectedRowKeys.includes(modelKey); + const description = getModelDescription(model).trim(); const priceData = calculateModelPrice({ record: model, @@ -258,7 +330,6 @@ const PricingCardView = ({ onClick={() => openModelDetail && openModelDetail(model)} >
- {/* 头部:图标 + 模型名称 + 操作按钮 */}
{getModelIcon(model)} @@ -266,14 +337,13 @@ const PricingCardView = ({

{model.model_name}

-
- {formatPriceInfo(priceData, t, siteDisplayType)} +
+ {renderPriceSummary(model, priceData)}
- {/* 复制按钮 */}
- {/* 模型描述 - 占据剩余空间 */} -
-

- {getModelDescription(model)} -

-
+ {description ? ( +
+

+ {description} +

+
+ ) : ( +
+ )} - {/* 底部区域 */}
- {/* 标签区域 */} {renderTags(model)} - {/* 倍率信息(可选) */} {showRatio && (
@@ -358,7 +427,6 @@ const PricingCardView = ({ })}
- {/* 分页 */} {filteredModels.length > 0 && (
Date: Sun, 29 Mar 2026 12:53:44 +0800 Subject: [PATCH 030/282] feat: add playground creation center --- web/src/components/playground/ChatArea.jsx | 56 +- .../playground/OptimizedComponents.js | 2 + .../playground/PlaygroundCreationCenter.jsx | 159 ++++++ .../components/playground/SettingsPanel.jsx | 202 ++++--- .../components/playground/configStorage.js | 2 + web/src/constants/playground.constants.js | 7 + web/src/helpers/api.js | 64 +-- web/src/helpers/index.js | 1 + web/src/helpers/playgroundMode.js | 108 ++++ web/src/hooks/playground/useApiRequest.jsx | 28 +- .../hooks/playground/usePlaygroundState.js | 10 + web/src/i18n/locales/en.json | 33 ++ web/src/i18n/locales/fr.json | 33 ++ web/src/i18n/locales/ja.json | 33 ++ web/src/i18n/locales/ru.json | 33 ++ web/src/i18n/locales/vi.json | 33 ++ web/src/i18n/locales/zh-CN.json | 33 ++ web/src/i18n/locales/zh-TW.json | 33 ++ web/src/pages/Playground/index.jsx | 520 +++++++++++------- 19 files changed, 1033 insertions(+), 357 deletions(-) create mode 100644 web/src/components/playground/PlaygroundCreationCenter.jsx create mode 100644 web/src/helpers/playgroundMode.js diff --git a/web/src/components/playground/ChatArea.jsx b/web/src/components/playground/ChatArea.jsx index 2c65731f5487..ca0f60179cfe 100644 --- a/web/src/components/playground/ChatArea.jsx +++ b/web/src/components/playground/ChatArea.jsx @@ -18,15 +18,14 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui'; -import { MessageSquare, Eye, EyeOff } from 'lucide-react'; +import { Button, Card, Chat, Typography } from '@douyinfe/semi-ui'; +import { Eye, EyeOff, MessageSquare } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import CustomInputRender from './CustomInputRender'; const ChatArea = ({ chatRef, message, - inputs, styleState, showDebugPanel, roleInfo, @@ -39,6 +38,9 @@ const ChatArea = ({ onToggleDebugPanel, renderCustomChatContent, renderChatBoxAction, + title, + subtitle, + placeholder, }) => { const { t } = useTranslation(); @@ -48,52 +50,48 @@ const ChatArea = ({ return ( - {/* 聊天头部 */} {styleState.isMobile ? ( -
+
) : ( -
-
-
-
- +
+
+
+
+
-
+
- {t('AI 对话')} + {title || t('AI 对话')} - {inputs.model || t('选择模型开始对话')} + {subtitle || t('选择模型开始创作')}
-
- -
+
)} - {/* 聊天内容区域 */}
diff --git a/web/src/components/playground/OptimizedComponents.js b/web/src/components/playground/OptimizedComponents.js index ff679c6591c8..1ef86b960227 100644 --- a/web/src/components/playground/OptimizedComponents.js +++ b/web/src/components/playground/OptimizedComponents.js @@ -70,6 +70,8 @@ export const OptimizedSettingsPanel = React.memo( JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) && prevProps.customRequestMode === nextProps.customRequestMode && prevProps.customRequestBody === nextProps.customRequestBody && + prevProps.playgroundMode === nextProps.playgroundMode && + prevProps.modeHasAvailableModels === nextProps.modeHasAvailableModels && prevProps.showDebugPanel === nextProps.showDebugPanel && prevProps.showSettings === nextProps.showSettings && JSON.stringify(prevProps.previewPayload) === diff --git a/web/src/components/playground/PlaygroundCreationCenter.jsx b/web/src/components/playground/PlaygroundCreationCenter.jsx new file mode 100644 index 000000000000..e2eba51b3746 --- /dev/null +++ b/web/src/components/playground/PlaygroundCreationCenter.jsx @@ -0,0 +1,159 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Card, Typography } from '@douyinfe/semi-ui'; +import { Clapperboard, ImagePlus, MessageSquareText, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { PLAYGROUND_MODES } from '../../helpers'; + +const MODE_CARDS = { + [PLAYGROUND_MODES.CHAT]: { + icon: MessageSquareText, + titleKey: '智能对话', + descriptionKey: '适合长上下文、多轮追问、提示词调试和结构化输出。', + accent: 'from-sky-500 via-cyan-400 to-blue-500', + }, + [PLAYGROUND_MODES.IMAGE]: { + icon: ImagePlus, + titleKey: '图片创作', + descriptionKey: '围绕提示词、尺寸比例和参考图来生成或编辑图像。', + accent: 'from-amber-500 via-orange-400 to-rose-400', + }, + [PLAYGROUND_MODES.VIDEO]: { + icon: Clapperboard, + titleKey: '视频创作', + descriptionKey: '聚焦时长、清晰度和参考模式,快速组织视频生成任务。', + accent: 'from-fuchsia-500 via-rose-500 to-orange-400', + }, +}; + +const PlaygroundCreationCenter = ({ + playgroundMode, + onModeChange, + modeCounts, + currentModel, +}) => { + const { t } = useTranslation(); + + return ( + +
+
+
+
+
+
+ + {t('创作中心')} +
+ + {t('在一个操练场里切换对话、图片和视频创作')} + + + {t('下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。')} + +
+
+
+ {t('当前模型')} +
+
+ {currentModel || t('未选择模型')} +
+
+
+ +
+ {Object.entries(MODE_CARDS).map(([mode, config]) => { + const Icon = config.icon; + const isActive = playgroundMode === mode; + const count = modeCounts?.[mode] || 0; + + return ( + + ); + })} +
+
+
+ + ); +}; + +export default PlaygroundCreationCenter; diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 57b780d660ab..baa00a4121f6 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -18,14 +18,67 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui'; -import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react'; +import { Button, Card, Select, Switch, Typography } from '@douyinfe/semi-ui'; +import { + Clapperboard, + ImagePlus, + MessageSquareText, + Settings, + Sparkles, + ToggleLeft, + Users, + X, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { renderGroupOption, selectFilter } from '../../helpers'; -import ParameterControl from './ParameterControl'; -import ImageUrlInput from './ImageUrlInput'; +import { + isAdobeImage4KModel, + isAdobeImageModel, + isAdobeSoraModel, + isAdobeVeoModel, + isAdobeVideoModel, + isGrokImagineImageModel, + isGrokImagineVideoModel, + isVideoModeModel, + PLAYGROUND_MODES, + renderGroupOption, + selectFilter, +} from '../../helpers'; import ConfigManager from './ConfigManager'; import CustomRequestEditor from './CustomRequestEditor'; +import ImageUrlInput from './ImageUrlInput'; +import ParameterControl from './ParameterControl'; + +const MODE_SUMMARY_STYLES = { + [PLAYGROUND_MODES.CHAT]: { + icon: MessageSquareText, + titleKey: '智能对话', + descriptionKey: '围绕系统提示词、多轮消息和流式响应来组织创作。', + accent: 'from-sky-500 to-cyan-400', + }, + [PLAYGROUND_MODES.IMAGE]: { + icon: ImagePlus, + titleKey: '图片创作', + descriptionKey: + '优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。', + accent: 'from-amber-500 to-orange-400', + }, + [PLAYGROUND_MODES.VIDEO]: { + icon: Clapperboard, + titleKey: '视频创作', + descriptionKey: '聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。', + accent: 'from-rose-500 to-fuchsia-500', + }, +}; + +const normalizeGrokImageSize = (size) => { + if (size === '1536x1024') { + return '1792x1024'; + } + if (size === '1024x1536') { + return '1024x1792'; + } + return size; +}; const SettingsPanel = ({ inputs, @@ -45,51 +98,23 @@ const SettingsPanel = ({ onCustomRequestBodyChange, previewPayload, messages, + playgroundMode, + modeHasAvailableModels, }) => { const { t } = useTranslation(); - const normalizeGrokImageSize = (size) => { - if (size === '1536x1024') { - return '1792x1024'; - } - if (size === '1024x1536') { - return '1024x1792'; - } - return size; - }; - const grokImagineImageModels = new Set([ - 'grok-imagine-1.0', - 'grok-imagine-1.0-fast', - 'grok-imagine-1.0-edit', - ]); - const adobeImageModels = new Set([ - 'nano-banana', - 'nano-banana-4k', - 'nano-banana2', - 'nano-banana2-4k', - 'nano-banana-pro', - 'nano-banana-pro-4k', - ]); - const adobeVideoModels = new Set([ - 'sora2', - 'sora2-pro', - 'veo31', - 'veo31-ref', - 'veo31-fast', - ]); - const isGrokImagineImageModel = grokImagineImageModels.has(inputs.model); - const isAdobeImageModel = adobeImageModels.has(inputs.model); - const isAdobeVideoModel = adobeVideoModels.has(inputs.model); - const isAdobeImage4KModel = - typeof inputs.model === 'string' && inputs.model.endsWith('-4k'); - const isAdobeSoraModel = - inputs.model === 'sora2' || inputs.model === 'sora2-pro'; - const isAdobeVeoModel = - inputs.model === 'veo31' || - inputs.model === 'veo31-ref' || - inputs.model === 'veo31-fast'; - const isVideoModel = - typeof inputs.model === 'string' && inputs.model.includes('video'); - const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; + const modeSummary = + MODE_SUMMARY_STYLES[playgroundMode] || MODE_SUMMARY_STYLES[PLAYGROUND_MODES.CHAT]; + const ModeIcon = modeSummary.icon; + + const isCurrentGrokImagineImageModel = isGrokImagineImageModel(inputs.model); + const isCurrentAdobeImageModel = isAdobeImageModel(inputs.model); + const isCurrentAdobeVideoModel = isAdobeVideoModel(inputs.model); + const isCurrentAdobeImage4KModel = isAdobeImage4KModel(inputs.model); + const isCurrentAdobeSoraModel = isAdobeSoraModel(inputs.model); + const isCurrentAdobeVeoModel = isAdobeVeoModel(inputs.model); + const isCurrentVideoModel = isVideoModeModel(inputs.model); + const isCurrentGrokImagineVideoModel = isGrokImagineVideoModel(inputs.model); + const imageSizeOptions = [ { label: '1:1 方图 (1024x1024)', value: '1024x1024' }, { label: '3:2 横图 (1792x1024)', value: '1792x1024' }, @@ -104,9 +129,9 @@ const SettingsPanel = ({ { label: '1024x1792', value: '1024x1792' }, { label: '1024x1024', value: '1024x1024' }, ]; - const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((v) => ({ - label: `${v}s`, - value: String(v), + const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((value) => ({ + label: `${value}s`, + value: String(value), })); const videoPresetOptions = [ { label: 'Normal', value: 'normal' }, @@ -142,13 +167,13 @@ const SettingsPanel = ({ { label: '2K', value: '2K' }, ]; const adobe4KResolutionOptions = [{ label: '4K', value: '4K' }]; - const adobeSoraDurationOptions = [4, 8, 12].map((v) => ({ - label: `${v}s`, - value: String(v), + const adobeSoraDurationOptions = [4, 8, 12].map((value) => ({ + label: `${value}s`, + value: String(value), })); - const adobeVeoDurationOptions = [4, 6, 8].map((v) => ({ - label: `${v}s`, - value: String(v), + const adobeVeoDurationOptions = [4, 6, 8].map((value) => ({ + label: `${value}s`, + value: String(value), })); const adobeVideoResolutionOptions = [ { label: '1080p', value: '1080p' }, @@ -165,6 +190,7 @@ const SettingsPanel = ({ showDebugPanel, customRequestMode, customRequestBody, + playgroundMode, }; return ( @@ -178,7 +204,6 @@ const SettingsPanel = ({ flexDirection: 'column', }} > - {/* 标题区域 - 与调试面板保持一致 */}
@@ -201,7 +226,6 @@ const SettingsPanel = ({ )}
- {/* 移动端配置管理 */} {styleState.isMobile && (
- {/* 自定义请求体编辑器 */} +
+
+
+
+ +
+
+ + {t(modeSummary.titleKey)} + + + {t(modeSummary.descriptionKey)} + + {!modeHasAvailableModels && ( + + {t( + '当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。', + )} + + )} +
+
+
+
+ - {/* 分组选择 */}
@@ -256,7 +307,6 @@ const SettingsPanel = ({ />
- {/* 模型选择 */}
@@ -287,7 +337,6 @@ const SettingsPanel = ({ />
- {/* 图片URL输入 */}
- {/* 参数控制组件 */}
- {/* 视频参数(仅视频模型显示) */} - {isGrokImagineImageModel && ( + {isCurrentGrokImagineImageModel && (
@@ -329,7 +376,7 @@ const SettingsPanel = ({
)} - {isAdobeImageModel && ( + {isCurrentAdobeImageModel && (
@@ -365,26 +412,27 @@ const SettingsPanel = ({
- {isAdobeVeoModel && ( + {isCurrentAdobeVeoModel && (
Resolution @@ -510,7 +558,6 @@ const SettingsPanel = ({
)} - {/* 流式输出开关 */}
@@ -536,7 +583,6 @@ const SettingsPanel = ({
- {/* 桌面端的配置管理放在底部 */} {!styleState.isMobile && (
{ parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode, customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody, + playgroundMode: + parsedConfig.playgroundMode || DEFAULT_CONFIG.playgroundMode, }; return mergedConfig; diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index f11251d84dc1..cd1d12a9c4e7 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -30,6 +30,12 @@ export const MESSAGE_ROLES = { SYSTEM: 'system', }; +export const PLAYGROUND_MODES = { + CHAT: 'chat', + IMAGE: 'image', + VIDEO: 'video', +}; + // 默认消息示例 - 使用函数生成以支持 i18n export const getDefaultMessages = (t) => [ { @@ -121,6 +127,7 @@ export const DEFAULT_CONFIG = { showDebugPanel: false, customRequestMode: false, customRequestBody: '', + playgroundMode: PLAYGROUND_MODES.CHAT, }; // ========== 正则表达式 ========== diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index baa136c550ab..2173826a6c44 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -25,6 +25,15 @@ import { } from './utils'; import axios from 'axios'; import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { + isAdobeImage4KModel, + isAdobeImageModel, + isAdobeVeoModel, + isAdobeVideoModel, + isGrokImagineImageModel, + isGrokImagineVideoModel, + isVideoModeModel, +} from './playgroundMode'; export let API = axios.create({ baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL @@ -124,26 +133,6 @@ export const buildApiPayload = ( } return size; }; - const grokImagineImageModels = new Set([ - 'grok-imagine-1.0', - 'grok-imagine-1.0-fast', - 'grok-imagine-1.0-edit', - ]); - const adobeImageModels = new Set([ - 'nano-banana', - 'nano-banana-4k', - 'nano-banana2', - 'nano-banana2-4k', - 'nano-banana-pro', - 'nano-banana-pro-4k', - ]); - const adobeVideoModels = new Set([ - 'sora2', - 'sora2-pro', - 'veo31', - 'veo31-ref', - 'veo31-fast', - ]); const processedMessages = messages .filter(isValidMessage) .map(formatMessageForAPI) @@ -184,35 +173,23 @@ export const buildApiPayload = ( } }); - const isVideoModel = - typeof inputs.model === 'string' && inputs.model.includes('video'); - const isGrokImagineImageModel = grokImagineImageModels.has(inputs.model); - const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; - const isAdobeImageModel = adobeImageModels.has(inputs.model); - const isAdobeVideoModel = adobeVideoModels.has(inputs.model); - const isAdobeImage4KModel = - typeof inputs.model === 'string' && inputs.model.endsWith('-4k'); - const isAdobeVeoModel = - inputs.model === 'veo31' || - inputs.model === 'veo31-ref' || - inputs.model === 'veo31-fast'; const adobeAspectRatioRaw = - inputs.aspectRatio || (isAdobeVideoModel ? '16:9' : '1:1'); + inputs.aspectRatio || (isAdobeVideoModel(inputs.model) ? '16:9' : '1:1'); const adobeAspectRatio = adobeAspectRatioRaw === 'auto' ? '' : adobeAspectRatioRaw; - if (isGrokImagineImageModel) { + if (isGrokImagineImageModel(inputs.model)) { payload.stream = false; if (inputs.imageSize) { payload.size = normalizeGrokImageSize(inputs.imageSize); } } - if (isAdobeImageModel) { + if (isAdobeImageModel(inputs.model)) { if (adobeAspectRatio) { payload.aspect_ratio = adobeAspectRatio; } else if (inputs.autoImageSize) { payload.size = inputs.autoImageSize; } - if (isAdobeImage4KModel) { + if (isAdobeImage4KModel(inputs.model)) { payload.output_resolution = '4K'; } else if (inputs.outputResolution) { payload.output_resolution = inputs.outputResolution; @@ -220,7 +197,7 @@ export const buildApiPayload = ( payload.output_resolution = '2K'; } } - if (isVideoModel) { + if (isVideoModeModel(inputs.model)) { payload.stream = false; if (inputs.videoSize) { payload.size = inputs.videoSize; @@ -241,14 +218,17 @@ export const buildApiPayload = ( : resolutionName === '480p' ? 'standard' : resolutionName; - if (isGrokImagineVideoModel && resolutionName) { + if (isGrokImagineVideoModel(inputs.model) && resolutionName) { payload.resolution_name = resolutionName; } } - if (isGrokImagineVideoModel && inputs.videoPreset) { + if (isGrokImagineVideoModel(inputs.model) && inputs.videoPreset) { payload.preset = inputs.videoPreset; } - if (isGrokImagineVideoModel && (payload.resolution_name || payload.preset)) { + if ( + isGrokImagineVideoModel(inputs.model) && + (payload.resolution_name || payload.preset) + ) { payload.video_config = { ...(payload.resolution_name ? { resolution_name: payload.resolution_name } @@ -257,10 +237,10 @@ export const buildApiPayload = ( }; } } - if (isAdobeVideoModel) { + if (isAdobeVideoModel(inputs.model)) { payload.duration = Number(inputs.videoDuration || 4); payload.aspect_ratio = adobeAspectRatio; - if (isAdobeVeoModel) { + if (isAdobeVeoModel(inputs.model)) { payload.resolution = inputs.videoResolution || '1080p'; } if (inputs.model === 'veo31-ref') { diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js index a86c3bca5996..3c337574d0a2 100644 --- a/web/src/helpers/index.js +++ b/web/src/helpers/index.js @@ -30,3 +30,4 @@ export * from './boolean'; export * from './dashboard'; export * from './passkey'; export * from './statusCodeRules'; +export * from './playgroundMode'; diff --git a/web/src/helpers/playgroundMode.js b/web/src/helpers/playgroundMode.js new file mode 100644 index 000000000000..821166b83b95 --- /dev/null +++ b/web/src/helpers/playgroundMode.js @@ -0,0 +1,108 @@ +export const PLAYGROUND_MODES = { + CHAT: 'chat', + IMAGE: 'image', + VIDEO: 'video', +}; + +const GROK_IMAGE_GENERATION_MODELS = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', +]); + +const GROK_IMAGE_EDIT_MODELS = new Set(['grok-imagine-1.0-edit']); + +const ADOBE_IMAGE_MODELS = new Set([ + 'nano-banana', + 'nano-banana-4k', + 'nano-banana2', + 'nano-banana2-4k', + 'nano-banana-pro', + 'nano-banana-pro-4k', +]); + +const ADOBE_VIDEO_MODELS = new Set([ + 'sora2', + 'sora2-pro', + 'veo31', + 'veo31-ref', + 'veo31-fast', +]); + +export const isGrokImagineImageGenerationModel = (model) => + GROK_IMAGE_GENERATION_MODELS.has(model); + +export const isGrokImagineImageEditModel = (model) => + GROK_IMAGE_EDIT_MODELS.has(model); + +export const isGrokImagineImageModel = (model) => + isGrokImagineImageGenerationModel(model) || isGrokImagineImageEditModel(model); + +export const usesDedicatedImageGenerationEndpoint = (model) => + isGrokImagineImageModel(model); + +export const isAdobeImageModel = (model) => ADOBE_IMAGE_MODELS.has(model); + +export const isAdobeImage4KModel = (model) => + typeof model === 'string' && model.endsWith('-4k'); + +export const isImageModeModel = (model) => + isGrokImagineImageModel(model) || isAdobeImageModel(model); + +export const isGrokImagineVideoModel = (model) => + model === 'grok-imagine-1.0-video'; + +export const isAdobeVideoModel = (model) => ADOBE_VIDEO_MODELS.has(model); + +export const isAdobeSoraModel = (model) => + model === 'sora2' || model === 'sora2-pro'; + +export const isAdobeVeoModel = (model) => + model === 'veo31' || model === 'veo31-ref' || model === 'veo31-fast'; + +export const isVideoModeModel = (model) => + isGrokImagineVideoModel(model) || isAdobeVideoModel(model); + +export const isChatModeModel = (model) => + typeof model === 'string' && + model.trim() !== '' && + !isImageModeModel(model) && + !isVideoModeModel(model); + +export const isModelCompatibleWithPlaygroundMode = (model, mode) => { + switch (mode) { + case PLAYGROUND_MODES.IMAGE: + return isImageModeModel(model); + case PLAYGROUND_MODES.VIDEO: + return isVideoModeModel(model); + case PLAYGROUND_MODES.CHAT: + default: + return isChatModeModel(model); + } +}; + +export const getModelValues = (models = []) => + models + .map((item) => { + if (typeof item === 'string') { + return item; + } + return item?.value; + }) + .filter((item) => typeof item === 'string' && item.trim() !== ''); + +export const getAvailableModelsForPlaygroundMode = (models = [], mode) => + getModelValues(models).filter((model) => + isModelCompatibleWithPlaygroundMode(model, mode), + ); + +export const getPreferredModelForPlaygroundMode = ( + currentModel, + models = [], + mode, +) => { + if (isModelCompatibleWithPlaygroundMode(currentModel, mode)) { + return currentModel; + } + + return getAvailableModelsForPlaygroundMode(models, mode)[0] || ''; +}; diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 9f9ac036d6ea..c269a2daf856 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -28,15 +28,13 @@ import { import { getUserIdFromLocalStorage, handleApiError, + isGrokImagineImageEditModel, + isGrokImagineVideoModel, processThinkTags, processIncompleteThinkTags, + usesDedicatedImageGenerationEndpoint, + isVideoModeModel, } from '../../helpers'; - -const GROK_IMAGE_GENERATION_MODELS = new Set([ - 'grok-imagine-1.0', - 'grok-imagine-1.0-fast', -]); -const GROK_IMAGE_EDIT_MODELS = new Set(['grok-imagine-1.0-edit']); const normalizeGrokImageSize = (size) => { if (size === '1536x1024') { return '1792x1024'; @@ -56,27 +54,15 @@ export const useApiRequest = ( ) => { const { t } = useTranslation(); - const isGrokImagineImageModel = useCallback((model) => { - return ( - GROK_IMAGE_GENERATION_MODELS.has(model) || GROK_IMAGE_EDIT_MODELS.has(model) - ); - }, []); - - const isGrokImagineImageEditModel = useCallback((model) => { - return GROK_IMAGE_EDIT_MODELS.has(model); - }, []); - const isVideoGenerationPayload = useCallback((payload) => { - const model = payload?.model; - return typeof model === 'string' && model.includes('video'); + return isVideoModeModel(payload?.model); }, []); const isImageGenerationPayload = useCallback( (payload) => { - const model = payload?.model; - return typeof model === 'string' && isGrokImagineImageModel(model); + return usesDedicatedImageGenerationEndpoint(payload?.model); }, - [isGrokImagineImageModel], + [], ); const getTextFromMessageContent = useCallback((content) => { diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index 79be10134adf..fb137db23d60 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -78,6 +78,9 @@ export const usePlaygroundState = () => { const [customRequestBody, setCustomRequestBody] = useState( savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody, ); + const [playgroundMode, setPlaygroundMode] = useState( + savedConfig.playgroundMode || DEFAULT_CONFIG.playgroundMode, + ); // UI状态 const [showSettings, setShowSettings] = useState(false); @@ -153,6 +156,7 @@ export const usePlaygroundState = () => { showDebugPanel, customRequestMode, customRequestBody, + playgroundMode, }; saveConfig(configToSave); }, 1000); @@ -162,6 +166,7 @@ export const usePlaygroundState = () => { showDebugPanel, customRequestMode, customRequestBody, + playgroundMode, ]); // 配置导入/重置 @@ -184,6 +189,9 @@ export const usePlaygroundState = () => { if (importedConfig.customRequestBody) { setCustomRequestBody(importedConfig.customRequestBody); } + if (importedConfig.playgroundMode) { + setPlaygroundMode(importedConfig.playgroundMode); + } // 如果导入的配置包含消息,也恢复消息 if (importedConfig.messages && Array.isArray(importedConfig.messages)) { setMessage(importedConfig.messages); @@ -198,6 +206,7 @@ export const usePlaygroundState = () => { setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel); setCustomRequestMode(DEFAULT_CONFIG.customRequestMode); setCustomRequestBody(DEFAULT_CONFIG.customRequestBody); + setPlaygroundMode(DEFAULT_CONFIG.playgroundMode); // 只有在明确指定时才重置消息 if (resetMessages) { @@ -284,6 +293,7 @@ export const usePlaygroundState = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, + setPlaygroundMode, setShowSettings, setModels, setGroups, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e392379e9455..bc1d6f7d4388 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3348,5 +3348,38 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens", "例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$", "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching." + , + "创作中心": "Creation Center", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "当前模型": "Current model", + "未选择模型": "No model selected", + "智能对话": "Smart Chat", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", + "图片创作": "Image Creation", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", + "视频创作": "Video Creation", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", + "可用模型": "Available models", + "当前模式": "Current mode", + "切换到此模式": "Switch to this mode", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", + "智能对话工作区": "Smart Chat Workspace", + "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", + "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", + "图片创作工作区": "Image Creation Workspace", + "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", + "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", + "视频创作工作区": "Video Creation Workspace", + "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", + "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 6ff22ab56639..15036268f0b2 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3309,5 +3309,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens" + , + "创作中心": "Creation Center", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "当前模型": "Current model", + "未选择模型": "No model selected", + "智能对话": "Smart Chat", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", + "图片创作": "Image Creation", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", + "视频创作": "Video Creation", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", + "可用模型": "Available models", + "当前模式": "Current mode", + "切换到此模式": "Switch to this mode", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", + "智能对话工作区": "Smart Chat Workspace", + "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", + "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", + "图片创作工作区": "Image Creation Workspace", + "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", + "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", + "视频创作工作区": "Video Creation Workspace", + "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", + "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index b2a59fc4e817..954c5b50ca47 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3290,5 +3290,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens" + , + "创作中心": "Creation Center", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "当前模型": "Current model", + "未选择模型": "No model selected", + "智能对话": "Smart Chat", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", + "图片创作": "Image Creation", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", + "视频创作": "Video Creation", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", + "可用模型": "Available models", + "当前模式": "Current mode", + "切换到此模式": "Switch to this mode", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", + "智能对话工作区": "Smart Chat Workspace", + "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", + "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", + "图片创作工作区": "Image Creation Workspace", + "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", + "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", + "视频创作工作区": "Video Creation Workspace", + "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", + "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index ccf102425e13..9c8512560f11 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3323,5 +3323,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens" + , + "创作中心": "Creation Center", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "当前模型": "Current model", + "未选择模型": "No model selected", + "智能对话": "Smart Chat", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", + "图片创作": "Image Creation", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", + "视频创作": "Video Creation", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", + "可用模型": "Available models", + "当前模式": "Current mode", + "切换到此模式": "Switch to this mode", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", + "智能对话工作区": "Smart Chat Workspace", + "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", + "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", + "图片创作工作区": "Image Creation Workspace", + "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", + "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", + "视频创作工作区": "Video Creation Workspace", + "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", + "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 5c9e1e8c0976..3d25ece00f48 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3860,5 +3860,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens" + , + "创作中心": "Creation Center", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "当前模型": "Current model", + "未选择模型": "No model selected", + "智能对话": "Smart Chat", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", + "图片创作": "Image Creation", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", + "视频创作": "Video Creation", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", + "可用模型": "Available models", + "当前模式": "Current mode", + "切换到此模式": "Switch to this mode", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", + "智能对话工作区": "Smart Chat Workspace", + "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", + "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", + "图片创作工作区": "Image Creation Workspace", + "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", + "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", + "视频创作工作区": "Video Creation Workspace", + "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", + "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 6b754ccde302..f47286e36607 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2957,5 +2957,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" + , + "创作中心": "创作中心", + "在一个操练场里切换对话、图片和视频创作": "在一个操练场里切换对话、图片和视频创作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。", + "当前模型": "当前模型", + "未选择模型": "未选择模型", + "智能对话": "智能对话", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "适合长上下文、多轮追问、提示词调试和结构化输出。", + "图片创作": "图片创作", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "围绕提示词、尺寸比例和参考图来生成或编辑图像。", + "视频创作": "视频创作", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "聚焦时长、清晰度和参考模式,快速组织视频生成任务。", + "可用模型": "可用模型", + "当前模式": "当前模式", + "切换到此模式": "切换到此模式", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "围绕系统提示词、多轮消息和流式响应来组织创作。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。", + "智能对话工作区": "智能对话工作区", + "输入你的问题、任务或提示词方向...": "输入你的问题、任务或提示词方向...", + "当前账号暂无适合智能对话的模型": "当前账号暂无适合智能对话的模型", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。", + "图片创作工作区": "图片创作工作区", + "可继续使用文生图与带图编辑能力": "可继续使用文生图与带图编辑能力", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "描述想要生成的画面,或先开启图片输入后再进行图片编辑...", + "当前账号暂无适合图片创作的模型": "当前账号暂无适合图片创作的模型", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。", + "视频创作工作区": "视频创作工作区", + "可继续使用文生视频与参考图视频能力": "可继续使用文生视频与参考图视频能力", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...", + "当前账号暂无适合视频创作的模型": "当前账号暂无适合视频创作的模型", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。" } } diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 4e2f63564a24..8babb2ad0487 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2974,5 +2974,38 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens" + , + "创作中心": "創作中心", + "在一个操练场里切换对话、图片和视频创作": "在一個操練場裡切換對話、圖片和影片創作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作區會繼續沿用現有操練場能力,你只需要在這裡選擇創作模式,系統會優先幫你對齊合適的模型與配置。", + "当前模型": "目前模型", + "未选择模型": "尚未選擇模型", + "智能对话": "智慧對話", + "适合长上下文、多轮追问、提示词调试和结构化输出。": "適合長上下文、多輪追問、提示詞調試與結構化輸出。", + "图片创作": "圖片創作", + "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "圍繞提示詞、尺寸比例與參考圖來生成或編輯圖像。", + "视频创作": "影片創作", + "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "聚焦時長、清晰度與參考模式,快速組織影片生成任務。", + "可用模型": "可用模型", + "当前模式": "目前模式", + "切换到此模式": "切換到此模式", + "围绕系统提示词、多轮消息和流式响应来组织创作。": "圍繞系統提示詞、多輪訊息與串流回應來組織創作。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓同一套操練場工作區更適合圖像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用操練場現有的影片生成鏈路。", + "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "目前帳號下暫無適合此創作模式的模型,可切換模式或保留完整模型列表查看。", + "智能对话工作区": "智慧對話工作區", + "输入你的问题、任务或提示词方向...": "輸入你的問題、任務或提示詞方向...", + "当前账号暂无适合智能对话的模型": "目前帳號下暫無適合智慧對話的模型", + "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "可以先切換到圖片創作或影片創作,也可以在左側模型配置中查看目前帳號返回的完整模型列表。", + "图片创作工作区": "圖片創作工作區", + "可继续使用文生图与带图编辑能力": "可繼續使用文生圖與帶圖編輯能力", + "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "描述想要生成的畫面,或先開啟圖片輸入後再進行圖片編輯...", + "当前账号暂无适合图片创作的模型": "目前帳號下暫無適合圖片創作的模型", + "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "圖片創作會優先匹配圖片模型;如果目前帳號沒有返回相關模型,請切換模式或等待模型配置更新。", + "视频创作工作区": "影片創作工作區", + "可继续使用文生视频与参考图视频能力": "可繼續使用文生影片與參考圖影片能力", + "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "描述想生成的影片鏡頭、節奏和風格,必要時可先開啟圖片輸入作為參考圖...", + "当前账号暂无适合视频创作的模型": "目前帳號下暫無適合影片創作的模型", + "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "影片創作會優先匹配影片模型;如果目前帳號尚未返回相關模型,可先切換到其它創作模式。" } } diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx index 68a97c335bde..d8d9f4b9e002 100644 --- a/web/src/pages/Playground/index.jsx +++ b/web/src/pages/Playground/index.jsx @@ -17,51 +17,45 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useCallback, useRef } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; - -// Context +import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; +import { AlertTriangle } from 'lucide-react'; import { UserContext } from '../../context/User'; import { useIsMobile } from '../../hooks/common/useIsMobile'; - -// hooks import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; import { useMessageActions } from '../../hooks/playground/useMessageActions'; import { useApiRequest } from '../../hooks/playground/useApiRequest'; import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; import { useDataLoader } from '../../hooks/playground/useDataLoader'; - -// Constants and utils -import { - MESSAGE_ROLES, - ERROR_MESSAGES, -} from '../../constants/playground.constants'; +import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; import { - getLogo, - stringToColor, + buildApiPayload, buildMessageContent, - createMessage, createLoadingAssistantMessage, - getTextContent, - buildApiPayload, + createMessage, encodeToBase64, + getAvailableModelsForPlaygroundMode, + getLogo, + getPreferredModelForPlaygroundMode, + getTextContent, + isModelCompatibleWithPlaygroundMode, + PLAYGROUND_MODES, + stringToColor, } from '../../helpers'; - -// Components import { - OptimizedSettingsPanel, OptimizedDebugPanel, - OptimizedMessageContent, OptimizedMessageActions, + OptimizedMessageContent, + OptimizedSettingsPanel, } from '../../components/playground/OptimizedComponents'; import ChatArea from '../../components/playground/ChatArea'; import FloatingButtons from '../../components/playground/FloatingButtons'; +import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; -// 生成头像 const generateAvatarDataUrl = (username) => { if (!username) { return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'; @@ -91,10 +85,10 @@ const Playground = () => { showDebugPanel, customRequestMode, customRequestBody, + playgroundMode, showSettings, models, groups, - status, message, debugData, activeDebugTab, @@ -110,7 +104,6 @@ const Playground = () => { setShowSettings, setModels, setGroups, - setStatus, setMessage, setDebugData, setActiveDebugTab, @@ -118,9 +111,9 @@ const Playground = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, + setPlaygroundMode, } = state; - // API 请求相关 const { sendRequest, onStopGenerator } = useApiRequest( setMessage, setDebugData, @@ -129,10 +122,8 @@ const Playground = () => { saveMessagesImmediately, ); - // 数据加载 useDataLoader(userState, inputs, handleInputChange, setModels, setGroups); - // 消息编辑 const { editingMessageId, editValue, @@ -148,7 +139,6 @@ const Playground = () => { saveMessagesImmediately, ); - // 消息和自定义请求体同步 const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody( customRequestMode, @@ -160,7 +150,6 @@ const Playground = () => { debouncedSaveConfig, ); - // 角色信息 const roleInfo = { user: { name: userState?.user?.username || 'User', @@ -176,51 +165,94 @@ const Playground = () => { }, }; - // 消息操作 - const messageActions = useMessageActions( - message, - setMessage, - onMessageSend, - saveMessagesImmediately, - ); + const availableModeModels = { + [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.CHAT, + ), + [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.IMAGE, + ), + [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.VIDEO, + ), + }; + const modelsLoaded = models.length > 0; + const modeCounts = { + [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, + [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, + [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, + }; + const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; + + const modeUi = { + [PLAYGROUND_MODES.CHAT]: { + title: t('智能对话工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('选择模型开始创作'), + placeholder: t('输入你的问题、任务或提示词方向...'), + unavailableTitle: t('当前账号暂无适合智能对话的模型'), + unavailableDescription: t( + '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', + ), + }, + [PLAYGROUND_MODES.IMAGE]: { + title: t('图片创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生图与带图编辑能力'), + placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), + unavailableTitle: t('当前账号暂无适合图片创作的模型'), + unavailableDescription: t( + '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', + ), + }, + [PLAYGROUND_MODES.VIDEO]: { + title: t('视频创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生视频与参考图视频能力'), + placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), + unavailableTitle: t('当前账号暂无适合视频创作的模型'), + unavailableDescription: t( + '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', + ), + }, + }; + const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; - // 构建预览请求体 const constructPreviewPayload = useCallback(() => { try { - // 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体 if (customRequestMode && customRequestBody && customRequestBody.trim()) { try { return JSON.parse(customRequestBody); } catch (parseError) { - console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError); + console.warn('Failed to parse custom request body for preview:', parseError); } } - // 默认预览逻辑 - let messages = [...message]; - - // 如果存在用户消息 + const messages = [...message]; if ( !( messages.length === 0 || - messages.every((msg) => msg.role !== MESSAGE_ROLES.USER) + messages.every((item) => item.role !== MESSAGE_ROLES.USER) ) ) { - // 处理最后一个用户消息的图片 - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === MESSAGE_ROLES.USER) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === MESSAGE_ROLES.USER) { if (inputs.imageEnabled && inputs.imageUrls) { const validImageUrls = inputs.imageUrls.filter( (url) => url.trim() !== '', ); if (validImageUrls.length > 0) { - const textContent = getTextContent(messages[i]) || '示例消息'; - const content = buildMessageContent( - textContent, - validImageUrls, - true, - ); - messages[i] = { ...messages[i], content }; + const textContent = getTextContent(messages[index]) || '示例消息'; + messages[index] = { + ...messages[index], + content: buildMessageContent(textContent, validImageUrls, true), + }; } } break; @@ -230,103 +262,192 @@ const Playground = () => { return buildApiPayload(messages, null, inputs, parameterEnabled); } catch (error) { - console.error('构造预览请求体失败:', error); + console.error('Failed to construct preview payload:', error); return null; } - }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]); + }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); - // 发送消息 - function onMessageSend(content, attachment) { - console.log('attachment: ', attachment); + const handleModeChange = useCallback( + (nextMode) => { + setPlaygroundMode(nextMode); + if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } - // 创建用户消息和加载消息 - const userMessage = createMessage(MESSAGE_ROLES.USER, content); - const loadingMessage = createLoadingAssistantMessage(); + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + nextMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, + [ + handleInputChange, + inputs.imageEnabled, + inputs.model, + models, + setPlaygroundMode, + ], + ); - // 如果是自定义请求体模式 - if (customRequestMode && customRequestBody) { - try { - const customPayload = JSON.parse(customRequestBody); + useEffect(() => { + if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } + }, [handleInputChange, inputs.imageEnabled, playgroundMode]); - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessage, loadingMessage]; + useEffect(() => { + if (!modelsLoaded) { + return; + } - // 发送自定义请求体 - sendRequest(customPayload, customPayload.stream !== false); + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); + + const onMessageSend = useCallback( + (content, attachment) => { + console.log('attachment: ', attachment); + + if (!customRequestMode) { + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const resolvedModel = preferredModel || inputs.model; + + if (!modeHasAvailableModels || !resolvedModel) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } - // 发送消息后保存,传入新消息列表 - setTimeout(() => saveMessagesImmediately(newMessages), 0); + if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } - return newMessages; - }); - return; - } catch (error) { - console.error('自定义请求体JSON解析失败:', error); - Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); - return; + if (resolvedModel !== inputs.model) { + handleInputChange('model', resolvedModel); + } } - } - // 默认模式 - const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== ''); - const messageContent = buildMessageContent( - content, - validImageUrls, - inputs.imageEnabled, - ); - const userMessageWithImages = createMessage( - MESSAGE_ROLES.USER, - messageContent, - ); + const userMessage = createMessage(MESSAGE_ROLES.USER, content); + const loadingMessage = createLoadingAssistantMessage(); - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessageWithImages]; + if (customRequestMode && customRequestBody) { + try { + const customPayload = JSON.parse(customRequestBody); + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessage, loadingMessage]; + sendRequest(customPayload, customPayload.stream !== false); + setTimeout(() => saveMessagesImmediately(newMessages), 0); + return newMessages; + }); + return; + } catch (error) { + console.error('Failed to parse custom request body:', error); + Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); + return; + } + } - const payload = buildApiPayload( - newMessages, - null, - inputs, - parameterEnabled, + const validImageUrls = (inputs.imageUrls || []).filter( + (url) => url.trim() !== '', + ); + const messageContent = buildMessageContent( + content, + validImageUrls, + inputs.imageEnabled, + ); + const userMessageWithImages = createMessage( + MESSAGE_ROLES.USER, + messageContent, ); - sendRequest(payload, inputs.stream); - // 禁用图片模式 - if (inputs.imageEnabled) { - setTimeout(() => { - handleInputChange('imageEnabled', false); - }, 100); - } + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const requestInputs = + preferredModel && preferredModel !== inputs.model + ? { ...inputs, model: preferredModel } + : inputs; + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessageWithImages]; + const payload = buildApiPayload( + newMessages, + null, + requestInputs, + parameterEnabled, + ); + sendRequest(payload, requestInputs.stream); + + if (inputs.imageEnabled) { + setTimeout(() => { + handleInputChange('imageEnabled', false); + }, 100); + } - // 发送消息后保存,传入新消息列表(包含用户消息和加载消息) - const messagesWithLoading = [...newMessages, loadingMessage]; - setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); + const messagesWithLoading = [...newMessages, loadingMessage]; + setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); + return messagesWithLoading; + }); + }, + [ + activeModeUi.unavailableTitle, + customRequestBody, + customRequestMode, + handleInputChange, + inputs, + modeHasAvailableModels, + models, + parameterEnabled, + playgroundMode, + saveMessagesImmediately, + sendRequest, + setMessage, + ], + ); - return messagesWithLoading; - }); - } + const messageActions = useMessageActions( + message, + setMessage, + onMessageSend, + saveMessagesImmediately, + ); - // 切换推理展开状态 const toggleReasoningExpansion = useCallback( (messageId) => { setMessage((prevMessages) => - prevMessages.map((msg) => - msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT - ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded } - : msg, + prevMessages.map((item) => + item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT + ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } + : item, ), ); }, [setMessage], ); - // 渲染函数 const renderCustomChatContent = useCallback( - ({ message, className }) => { - const isCurrentlyEditing = editingMessageId === message.id; + ({ message: currentMessage, className }) => { + const isCurrentlyEditing = editingMessageId === currentMessage.id; return ( { ); }, [ - styleState, - editingMessageId, editValue, - handleEditSave, + editingMessageId, handleEditCancel, + handleEditSave, setEditValue, + styleState, toggleReasoningExpansion, ], ); @@ -353,7 +474,7 @@ const Playground = () => { (props) => { const { message: currentMessage } = props; const isAnyMessageGenerating = message.some( - (msg) => msg.status === 'loading' || msg.status === 'incomplete', + (item) => item.status === 'loading' || item.status === 'incomplete', ); const isCurrentlyEditing = editingMessageId === currentMessage.id; @@ -371,12 +492,9 @@ const Playground = () => { /> ); }, - [messageActions, styleState, message, editingMessageId, handleMessageEdit], + [editingMessageId, handleMessageEdit, message, messageActions, styleState], ); - // Effects - - // 同步消息和自定义请求体 useEffect(() => { syncMessageToCustomBody(); }, [message, syncMessageToCustomBody]); @@ -385,16 +503,12 @@ const Playground = () => { syncCustomBodyToMessage(); }, [customRequestBody, syncCustomBodyToMessage]); - // 处理URL参数 useEffect(() => { if (searchParams.get('expired')) { Toast.warning(t('登录过期,请重新登录!')); } }, [searchParams, t]); - // Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新 - - // 构建预览payload useEffect(() => { const timer = setTimeout(() => { const preview = constructPreviewPayload(); @@ -408,49 +522,43 @@ const Playground = () => { return () => clearTimeout(timer); }, [ - message, + constructPreviewPayload, + customRequestBody, + customRequestMode, inputs, + message, parameterEnabled, - customRequestMode, - customRequestBody, - constructPreviewPayload, - setPreviewPayload, setDebugData, + setPreviewPayload, ]); - // 自动保存配置 useEffect(() => { debouncedSaveConfig(); }, [ + customRequestBody, + customRequestMode, + debouncedSaveConfig, inputs, parameterEnabled, + playgroundMode, showDebugPanel, - customRequestMode, - customRequestBody, - debouncedSaveConfig, ]); - // 清空对话的处理函数 const handleClearMessages = useCallback(() => { setMessage([]); - // 清空对话后保存,传入空数组 setTimeout(() => saveMessagesImmediately([]), 0); - }, [setMessage, saveMessagesImmediately]); + }, [saveMessagesImmediately, setMessage]); - // 处理粘贴图片 const handlePasteImage = useCallback( (base64Data) => { if (!inputs.imageEnabled) { return; } - // 添加图片到 imageUrls 数组 - const newUrls = [...(inputs.imageUrls || []), base64Data]; - handleInputChange('imageUrls', newUrls); + handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); }, - [inputs.imageEnabled, inputs.imageUrls, handleInputChange], + [handleInputChange, inputs.imageEnabled, inputs.imageUrls], ); - // Playground Context 值 const playgroundContextValue = { onPasteImage: handlePasteImage, imageUrls: inputs.imageUrls || [], @@ -464,13 +572,13 @@ const Playground = () => { {(showSettings || !isMobile) && ( { showDebugPanel={showDebugPanel} customRequestMode={customRequestMode} customRequestBody={customRequestBody} + playgroundMode={playgroundMode} + modeHasAvailableModels={modeHasAvailableModels} onInputChange={handleInputChange} onParameterToggle={handleParameterToggle} onCloseSettings={() => setShowSettings(false)} @@ -497,42 +607,79 @@ const Playground = () => { )} -
-
- setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} +
+
+
- {/* 调试面板 - 桌面端 */} - {showDebugPanel && !isMobile && ( -
- + {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( +
+ +
+
+ +
+
+ + {activeModeUi.unavailableTitle} + + + {activeModeUi.unavailableDescription} + +
+
+
)} + +
+
+
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} + title={activeModeUi.title} + subtitle={activeModeUi.subtitle} + placeholder={activeModeUi.placeholder} + /> +
+ + {showDebugPanel && !isMobile && ( +
+ +
+ )} +
+
- {/* 调试面板 - 移动端覆盖层 */} {showDebugPanel && isMobile && (
{
)} - {/* 浮动按钮 */} Date: Sun, 29 Mar 2026 13:20:02 +0800 Subject: [PATCH 031/282] feat: separate creation center from playground --- web/src/App.jsx | 9 + web/src/components/layout/SiderBar.jsx | 6 + web/src/helpers/render.jsx | 3 + web/src/hooks/common/useSidebar.js | 1 + .../hooks/playground/usePlaygroundState.js | 1 + web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/fr.json | 8 +- web/src/i18n/locales/ja.json | 8 +- web/src/i18n/locales/ru.json | 8 +- web/src/i18n/locales/vi.json | 8 +- web/src/i18n/locales/zh-CN.json | 8 +- web/src/i18n/locales/zh-TW.json | 8 +- web/src/pages/CreationCenter/index.jsx | 711 ++++++++++++++++++ web/src/pages/Playground/index.jsx | 475 ++++-------- 14 files changed, 901 insertions(+), 361 deletions(-) create mode 100644 web/src/pages/CreationCenter/index.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc00b32..90febf7088a9 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -43,6 +43,7 @@ import Pricing from './pages/Pricing'; import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; +import CreationCenter from './pages/CreationCenter'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; @@ -147,6 +148,14 @@ function App() { } /> + + + + } + /> {} }) => { const chatMenuItems = useMemo(() => { const items = [ + { + text: t('创作中心'), + itemKey: 'creation', + to: '/creation', + }, { text: t('操练场'), itemKey: 'playground', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f472e..e0d8d8d4bef0 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -60,6 +60,7 @@ import { import { LayoutDashboard, + Sparkles, TerminalSquare, MessageSquare, Key, @@ -120,6 +121,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'playground': return ; + case 'creation': + return ; case 'chat': return ; case 'token': diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index cd74ada20280..68a3f9c9709c 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -28,6 +28,7 @@ const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; export const DEFAULT_ADMIN_CONFIG = { chat: { enabled: true, + creation: true, playground: true, chat: true, }, diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index fb137db23d60..bf7643da5531 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -167,6 +167,7 @@ export const usePlaygroundState = () => { customRequestMode, customRequestBody, playgroundMode, + playgroundMode, ]); // 配置导入/重置 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index bc1d6f7d4388..d6912bf913da 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3350,8 +3350,8 @@ "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching." , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3364,8 +3364,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 15036268f0b2..cc593168e42f 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3311,8 +3311,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3325,8 +3325,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 954c5b50ca47..6f239a74db62 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3292,8 +3292,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3306,8 +3306,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 9c8512560f11..bbb34040376c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3325,8 +3325,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3339,8 +3339,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 3d25ece00f48..90e334666b47 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3862,8 +3862,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3876,8 +3876,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index f47286e36607..e32ab9211b3a 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2959,8 +2959,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" , "创作中心": "创作中心", - "在一个操练场里切换对话、图片和视频创作": "在一个操练场里切换对话、图片和视频创作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。", + "在一个操练场里切换对话、图片和视频创作": "在创作中心切换智能对话、图片创作和视频创作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "创作中心会继续复用现有模型与请求链路,你只需要选择任务类型,系统会优先为你对齐合适的模型与配置。", "当前模型": "当前模型", "未选择模型": "未选择模型", "智能对话": "智能对话", @@ -2973,8 +2973,8 @@ "当前模式": "当前模式", "切换到此模式": "切换到此模式", "围绕系统提示词、多轮消息和流式响应来组织创作。": "围绕系统提示词、多轮消息和流式响应来组织创作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让当前创作工作区更适合图像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用现有视频生成链路。", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。", "智能对话工作区": "智能对话工作区", "输入你的问题、任务或提示词方向...": "输入你的问题、任务或提示词方向...", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 8babb2ad0487..185633d37439 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2976,8 +2976,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens" , "创作中心": "創作中心", - "在一个操练场里切换对话、图片和视频创作": "在一個操練場裡切換對話、圖片和影片創作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作區會繼續沿用現有操練場能力,你只需要在這裡選擇創作模式,系統會優先幫你對齊合適的模型與配置。", + "在一个操练场里切换对话、图片和视频创作": "在創作中心切換智慧對話、圖片創作與影片創作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "創作中心會繼續沿用現有模型與請求鏈路,你只需要選擇任務類型,系統會優先幫你對齊合適的模型與配置。", "当前模型": "目前模型", "未选择模型": "尚未選擇模型", "智能对话": "智慧對話", @@ -2990,8 +2990,8 @@ "当前模式": "目前模式", "切换到此模式": "切換到此模式", "围绕系统提示词、多轮消息和流式响应来组织创作。": "圍繞系統提示詞、多輪訊息與串流回應來組織創作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓同一套操練場工作區更適合圖像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用操練場現有的影片生成鏈路。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓當前創作工作區更適合圖像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用現有的影片生成鏈路。", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "目前帳號下暫無適合此創作模式的模型,可切換模式或保留完整模型列表查看。", "智能对话工作区": "智慧對話工作區", "输入你的问题、任务或提示词方向...": "輸入你的問題、任務或提示詞方向...", diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx new file mode 100644 index 000000000000..6e7e4acbbc1c --- /dev/null +++ b/web/src/pages/CreationCenter/index.jsx @@ -0,0 +1,711 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useCallback, useContext, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; +import { AlertTriangle } from 'lucide-react'; +import { UserContext } from '../../context/User'; +import { useIsMobile } from '../../hooks/common/useIsMobile'; +import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; +import { useMessageActions } from '../../hooks/playground/useMessageActions'; +import { useApiRequest } from '../../hooks/playground/useApiRequest'; +import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; +import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; +import { useDataLoader } from '../../hooks/playground/useDataLoader'; +import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; +import { + buildApiPayload, + buildMessageContent, + createLoadingAssistantMessage, + createMessage, + encodeToBase64, + getAvailableModelsForPlaygroundMode, + getLogo, + getPreferredModelForPlaygroundMode, + getTextContent, + isModelCompatibleWithPlaygroundMode, + PLAYGROUND_MODES, + stringToColor, +} from '../../helpers'; +import { + OptimizedDebugPanel, + OptimizedMessageActions, + OptimizedMessageContent, + OptimizedSettingsPanel, +} from '../../components/playground/OptimizedComponents'; +import ChatArea from '../../components/playground/ChatArea'; +import FloatingButtons from '../../components/playground/FloatingButtons'; +import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; +import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; + +const generateAvatarDataUrl = (username) => { + if (!username) { + return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'; + } + const firstLetter = username[0].toUpperCase(); + const bgColor = stringToColor(username); + const svg = ` + + + ${firstLetter} + + `; + return `data:image/svg+xml;base64,${encodeToBase64(svg)}`; +}; + +const CreationCenter = () => { + const { t } = useTranslation(); + const [userState] = useContext(UserContext); + const isMobile = useIsMobile(); + const styleState = { isMobile }; + const [searchParams] = useSearchParams(); + + const state = usePlaygroundState(); + const { + inputs, + parameterEnabled, + showDebugPanel, + customRequestMode, + customRequestBody, + playgroundMode, + showSettings, + models, + groups, + message, + debugData, + activeDebugTab, + previewPayload, + sseSourceRef, + chatRef, + handleInputChange, + handleParameterToggle, + debouncedSaveConfig, + saveMessagesImmediately, + handleConfigImport, + handleConfigReset, + setShowSettings, + setModels, + setGroups, + setMessage, + setDebugData, + setActiveDebugTab, + setPreviewPayload, + setShowDebugPanel, + setCustomRequestMode, + setCustomRequestBody, + setPlaygroundMode, + } = state; + + const { sendRequest, onStopGenerator } = useApiRequest( + setMessage, + setDebugData, + setActiveDebugTab, + sseSourceRef, + saveMessagesImmediately, + ); + + useDataLoader(userState, inputs, handleInputChange, setModels, setGroups); + + const { + editingMessageId, + editValue, + setEditValue, + handleMessageEdit, + handleEditSave, + handleEditCancel, + } = useMessageEdit( + setMessage, + inputs, + parameterEnabled, + sendRequest, + saveMessagesImmediately, + ); + + const { syncMessageToCustomBody, syncCustomBodyToMessage } = + useSyncMessageAndCustomBody( + customRequestMode, + customRequestBody, + message, + inputs, + setCustomRequestBody, + setMessage, + debouncedSaveConfig, + ); + + const roleInfo = { + user: { + name: userState?.user?.username || 'User', + avatar: generateAvatarDataUrl(userState?.user?.username), + }, + assistant: { + name: 'Assistant', + avatar: getLogo(), + }, + system: { + name: 'System', + avatar: getLogo(), + }, + }; + + const availableModeModels = { + [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.CHAT, + ), + [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.IMAGE, + ), + [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.VIDEO, + ), + }; + const modelsLoaded = models.length > 0; + const modeCounts = { + [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, + [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, + [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, + }; + const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; + + const modeUi = { + [PLAYGROUND_MODES.CHAT]: { + title: t('智能对话工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('选择模型开始创作'), + placeholder: t('输入你的问题、任务或提示词方向...'), + unavailableTitle: t('当前账号暂无适合智能对话的模型'), + unavailableDescription: t( + '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', + ), + }, + [PLAYGROUND_MODES.IMAGE]: { + title: t('图片创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生图与带图编辑能力'), + placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), + unavailableTitle: t('当前账号暂无适合图片创作的模型'), + unavailableDescription: t( + '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', + ), + }, + [PLAYGROUND_MODES.VIDEO]: { + title: t('视频创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生视频与参考图视频能力'), + placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), + unavailableTitle: t('当前账号暂无适合视频创作的模型'), + unavailableDescription: t( + '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', + ), + }, + }; + const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; + + const constructPreviewPayload = useCallback(() => { + try { + if (customRequestMode && customRequestBody && customRequestBody.trim()) { + try { + return JSON.parse(customRequestBody); + } catch (parseError) { + console.warn('Failed to parse custom request body for preview:', parseError); + } + } + + const messages = [...message]; + if ( + !( + messages.length === 0 || + messages.every((item) => item.role !== MESSAGE_ROLES.USER) + ) + ) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === MESSAGE_ROLES.USER) { + if (inputs.imageEnabled && inputs.imageUrls) { + const validImageUrls = inputs.imageUrls.filter( + (url) => url.trim() !== '', + ); + if (validImageUrls.length > 0) { + const textContent = getTextContent(messages[index]) || '示例消息'; + messages[index] = { + ...messages[index], + content: buildMessageContent(textContent, validImageUrls, true), + }; + } + } + break; + } + } + } + + return buildApiPayload(messages, null, inputs, parameterEnabled); + } catch (error) { + console.error('Failed to construct preview payload:', error); + return null; + } + }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); + + const handleModeChange = useCallback( + (nextMode) => { + setPlaygroundMode(nextMode); + if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } + + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + nextMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, + [ + handleInputChange, + inputs.imageEnabled, + inputs.model, + models, + setPlaygroundMode, + ], + ); + + useEffect(() => { + if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } + }, [handleInputChange, inputs.imageEnabled, playgroundMode]); + + useEffect(() => { + if (!modelsLoaded) { + return; + } + + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); + + const onMessageSend = useCallback( + (content, attachment) => { + console.log('attachment: ', attachment); + + if (!customRequestMode) { + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const resolvedModel = preferredModel || inputs.model; + + if (!modeHasAvailableModels || !resolvedModel) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } + + if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } + + if (resolvedModel !== inputs.model) { + handleInputChange('model', resolvedModel); + } + } + + const userMessage = createMessage(MESSAGE_ROLES.USER, content); + const loadingMessage = createLoadingAssistantMessage(); + + if (customRequestMode && customRequestBody) { + try { + const customPayload = JSON.parse(customRequestBody); + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessage, loadingMessage]; + sendRequest(customPayload, customPayload.stream !== false); + setTimeout(() => saveMessagesImmediately(newMessages), 0); + return newMessages; + }); + return; + } catch (error) { + console.error('Failed to parse custom request body:', error); + Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); + return; + } + } + + const validImageUrls = (inputs.imageUrls || []).filter( + (url) => url.trim() !== '', + ); + const messageContent = buildMessageContent( + content, + validImageUrls, + inputs.imageEnabled, + ); + const userMessageWithImages = createMessage( + MESSAGE_ROLES.USER, + messageContent, + ); + + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const requestInputs = + preferredModel && preferredModel !== inputs.model + ? { ...inputs, model: preferredModel } + : inputs; + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessageWithImages]; + const payload = buildApiPayload( + newMessages, + null, + requestInputs, + parameterEnabled, + ); + sendRequest(payload, requestInputs.stream); + + if (inputs.imageEnabled) { + setTimeout(() => { + handleInputChange('imageEnabled', false); + }, 100); + } + + const messagesWithLoading = [...newMessages, loadingMessage]; + setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); + return messagesWithLoading; + }); + }, + [ + activeModeUi.unavailableTitle, + customRequestBody, + customRequestMode, + handleInputChange, + inputs, + modeHasAvailableModels, + models, + parameterEnabled, + playgroundMode, + saveMessagesImmediately, + sendRequest, + setMessage, + ], + ); + + const messageActions = useMessageActions( + message, + setMessage, + onMessageSend, + saveMessagesImmediately, + ); + + const toggleReasoningExpansion = useCallback( + (messageId) => { + setMessage((prevMessages) => + prevMessages.map((item) => + item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT + ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } + : item, + ), + ); + }, + [setMessage], + ); + + const renderCustomChatContent = useCallback( + ({ message: currentMessage, className }) => { + const isCurrentlyEditing = editingMessageId === currentMessage.id; + + return ( + + ); + }, + [ + editValue, + editingMessageId, + handleEditCancel, + handleEditSave, + setEditValue, + styleState, + toggleReasoningExpansion, + ], + ); + + const renderChatBoxAction = useCallback( + (props) => { + const { message: currentMessage } = props; + const isAnyMessageGenerating = message.some( + (item) => item.status === 'loading' || item.status === 'incomplete', + ); + const isCurrentlyEditing = editingMessageId === currentMessage.id; + + return ( + + ); + }, + [editingMessageId, handleMessageEdit, message, messageActions, styleState], + ); + + useEffect(() => { + syncMessageToCustomBody(); + }, [message, syncMessageToCustomBody]); + + useEffect(() => { + syncCustomBodyToMessage(); + }, [customRequestBody, syncCustomBodyToMessage]); + + useEffect(() => { + if (searchParams.get('expired')) { + Toast.warning(t('登录过期,请重新登录!')); + } + }, [searchParams, t]); + + useEffect(() => { + const timer = setTimeout(() => { + const preview = constructPreviewPayload(); + setPreviewPayload(preview); + setDebugData((prev) => ({ + ...prev, + previewRequest: preview ? JSON.stringify(preview, null, 2) : null, + previewTimestamp: preview ? new Date().toISOString() : null, + })); + }, 300); + + return () => clearTimeout(timer); + }, [ + constructPreviewPayload, + customRequestBody, + customRequestMode, + inputs, + message, + parameterEnabled, + setDebugData, + setPreviewPayload, + ]); + + useEffect(() => { + debouncedSaveConfig(); + }, [ + customRequestBody, + customRequestMode, + debouncedSaveConfig, + inputs, + parameterEnabled, + playgroundMode, + showDebugPanel, + ]); + + const handleClearMessages = useCallback(() => { + setMessage([]); + setTimeout(() => saveMessagesImmediately([]), 0); + }, [saveMessagesImmediately, setMessage]); + + const handlePasteImage = useCallback( + (base64Data) => { + if (!inputs.imageEnabled) { + return; + } + handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); + }, + [handleInputChange, inputs.imageEnabled, inputs.imageUrls], + ); + + const playgroundContextValue = { + onPasteImage: handlePasteImage, + imageUrls: inputs.imageUrls || [], + imageEnabled: inputs.imageEnabled || false, + }; + + return ( + +
+ + {(showSettings || !isMobile) && ( + + setShowSettings(false)} + onConfigImport={handleConfigImport} + onConfigReset={handleConfigReset} + onCustomRequestModeChange={setCustomRequestMode} + onCustomRequestBodyChange={setCustomRequestBody} + previewPayload={previewPayload} + messages={message} + /> + + )} + + +
+
+ +
+ + {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( +
+ +
+
+ +
+
+ + {activeModeUi.unavailableTitle} + + + {activeModeUi.unavailableDescription} + +
+
+
+
+ )} + +
+
+
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} + title={activeModeUi.title} + subtitle={activeModeUi.subtitle} + placeholder={activeModeUi.placeholder} + /> +
+ + {showDebugPanel && !isMobile && ( +
+ +
+ )} +
+
+
+ + {showDebugPanel && isMobile && ( +
+ setShowDebugPanel(false)} + customRequestMode={customRequestMode} + /> +
+ )} + + setShowSettings(!showSettings)} + onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} + /> +
+
+
+
+ ); +}; + +export default CreationCenter; diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx index d8d9f4b9e002..788c17bdddd7 100644 --- a/web/src/pages/Playground/index.jsx +++ b/web/src/pages/Playground/index.jsx @@ -17,11 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useCallback, useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; -import { AlertTriangle } from 'lucide-react'; +import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; + import { UserContext } from '../../context/User'; import { useIsMobile } from '../../hooks/common/useIsMobile'; import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; @@ -30,30 +30,28 @@ import { useApiRequest } from '../../hooks/playground/useApiRequest'; import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; import { useDataLoader } from '../../hooks/playground/useDataLoader'; -import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; import { - buildApiPayload, + MESSAGE_ROLES, + ERROR_MESSAGES, +} from '../../constants/playground.constants'; +import { + getLogo, + stringToColor, buildMessageContent, - createLoadingAssistantMessage, createMessage, - encodeToBase64, - getAvailableModelsForPlaygroundMode, - getLogo, - getPreferredModelForPlaygroundMode, + createLoadingAssistantMessage, getTextContent, - isModelCompatibleWithPlaygroundMode, - PLAYGROUND_MODES, - stringToColor, + buildApiPayload, + encodeToBase64, } from '../../helpers'; import { + OptimizedSettingsPanel, OptimizedDebugPanel, - OptimizedMessageActions, OptimizedMessageContent, - OptimizedSettingsPanel, + OptimizedMessageActions, } from '../../components/playground/OptimizedComponents'; import ChatArea from '../../components/playground/ChatArea'; import FloatingButtons from '../../components/playground/FloatingButtons'; -import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; const generateAvatarDataUrl = (username) => { @@ -85,10 +83,10 @@ const Playground = () => { showDebugPanel, customRequestMode, customRequestBody, - playgroundMode, showSettings, models, groups, + status, message, debugData, activeDebugTab, @@ -104,6 +102,7 @@ const Playground = () => { setShowSettings, setModels, setGroups, + setStatus, setMessage, setDebugData, setActiveDebugTab, @@ -111,7 +110,6 @@ const Playground = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, - setPlaygroundMode, } = state; const { sendRequest, onStopGenerator } = useApiRequest( @@ -165,64 +163,12 @@ const Playground = () => { }, }; - const availableModeModels = { - [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.CHAT, - ), - [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.IMAGE, - ), - [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.VIDEO, - ), - }; - const modelsLoaded = models.length > 0; - const modeCounts = { - [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, - [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, - [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, - }; - const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; - - const modeUi = { - [PLAYGROUND_MODES.CHAT]: { - title: t('智能对话工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('选择模型开始创作'), - placeholder: t('输入你的问题、任务或提示词方向...'), - unavailableTitle: t('当前账号暂无适合智能对话的模型'), - unavailableDescription: t( - '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', - ), - }, - [PLAYGROUND_MODES.IMAGE]: { - title: t('图片创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生图与带图编辑能力'), - placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), - unavailableTitle: t('当前账号暂无适合图片创作的模型'), - unavailableDescription: t( - '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', - ), - }, - [PLAYGROUND_MODES.VIDEO]: { - title: t('视频创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生视频与参考图视频能力'), - placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), - unavailableTitle: t('当前账号暂无适合视频创作的模型'), - unavailableDescription: t( - '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', - ), - }, - }; - const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; + const messageActions = useMessageActions( + message, + setMessage, + onMessageSend, + saveMessagesImmediately, + ); const constructPreviewPayload = useCallback(() => { try { @@ -230,29 +176,32 @@ const Playground = () => { try { return JSON.parse(customRequestBody); } catch (parseError) { - console.warn('Failed to parse custom request body for preview:', parseError); + console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError); } } - const messages = [...message]; + let messages = [...message]; + if ( !( messages.length === 0 || - messages.every((item) => item.role !== MESSAGE_ROLES.USER) + messages.every((msg) => msg.role !== MESSAGE_ROLES.USER) ) ) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index].role === MESSAGE_ROLES.USER) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === MESSAGE_ROLES.USER) { if (inputs.imageEnabled && inputs.imageUrls) { const validImageUrls = inputs.imageUrls.filter( (url) => url.trim() !== '', ); if (validImageUrls.length > 0) { - const textContent = getTextContent(messages[index]) || '示例消息'; - messages[index] = { - ...messages[index], - content: buildMessageContent(textContent, validImageUrls, true), - }; + const textContent = getTextContent(messages[i]) || '示例消息'; + const content = buildMessageContent( + textContent, + validImageUrls, + true, + ); + messages[i] = { ...messages[i], content }; } } break; @@ -262,179 +211,80 @@ const Playground = () => { return buildApiPayload(messages, null, inputs, parameterEnabled); } catch (error) { - console.error('Failed to construct preview payload:', error); + console.error('构造预览请求体失败:', error); return null; } - }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); + }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]); - const handleModeChange = useCallback( - (nextMode) => { - setPlaygroundMode(nextMode); - if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } + function onMessageSend(content, attachment) { + console.log('attachment: ', attachment); - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - nextMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, - [ - handleInputChange, - inputs.imageEnabled, - inputs.model, - models, - setPlaygroundMode, - ], - ); + const userMessage = createMessage(MESSAGE_ROLES.USER, content); + const loadingMessage = createLoadingAssistantMessage(); - useEffect(() => { - if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } - }, [handleInputChange, inputs.imageEnabled, playgroundMode]); + if (customRequestMode && customRequestBody) { + try { + const customPayload = JSON.parse(customRequestBody); - useEffect(() => { - if (!modelsLoaded) { - return; - } + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessage, loadingMessage]; - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); - - const onMessageSend = useCallback( - (content, attachment) => { - console.log('attachment: ', attachment); - - if (!customRequestMode) { - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const resolvedModel = preferredModel || inputs.model; - - if (!modeHasAvailableModels || !resolvedModel) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } + sendRequest(customPayload, customPayload.stream !== false); - if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } + setTimeout(() => saveMessagesImmediately(newMessages), 0); - if (resolvedModel !== inputs.model) { - handleInputChange('model', resolvedModel); - } + return newMessages; + }); + return; + } catch (error) { + console.error('自定义请求体JSON解析失败:', error); + Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); + return; } + } - const userMessage = createMessage(MESSAGE_ROLES.USER, content); - const loadingMessage = createLoadingAssistantMessage(); + const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== ''); + const messageContent = buildMessageContent( + content, + validImageUrls, + inputs.imageEnabled, + ); + const userMessageWithImages = createMessage( + MESSAGE_ROLES.USER, + messageContent, + ); - if (customRequestMode && customRequestBody) { - try { - const customPayload = JSON.parse(customRequestBody); - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessage, loadingMessage]; - sendRequest(customPayload, customPayload.stream !== false); - setTimeout(() => saveMessagesImmediately(newMessages), 0); - return newMessages; - }); - return; - } catch (error) { - console.error('Failed to parse custom request body:', error); - Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); - return; - } - } + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessageWithImages]; - const validImageUrls = (inputs.imageUrls || []).filter( - (url) => url.trim() !== '', - ); - const messageContent = buildMessageContent( - content, - validImageUrls, - inputs.imageEnabled, - ); - const userMessageWithImages = createMessage( - MESSAGE_ROLES.USER, - messageContent, + const payload = buildApiPayload( + newMessages, + null, + inputs, + parameterEnabled, ); + sendRequest(payload, inputs.stream); - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const requestInputs = - preferredModel && preferredModel !== inputs.model - ? { ...inputs, model: preferredModel } - : inputs; - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessageWithImages]; - const payload = buildApiPayload( - newMessages, - null, - requestInputs, - parameterEnabled, - ); - sendRequest(payload, requestInputs.stream); - - if (inputs.imageEnabled) { - setTimeout(() => { - handleInputChange('imageEnabled', false); - }, 100); - } + if (inputs.imageEnabled) { + setTimeout(() => { + handleInputChange('imageEnabled', false); + }, 100); + } - const messagesWithLoading = [...newMessages, loadingMessage]; - setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); - return messagesWithLoading; - }); - }, - [ - activeModeUi.unavailableTitle, - customRequestBody, - customRequestMode, - handleInputChange, - inputs, - modeHasAvailableModels, - models, - parameterEnabled, - playgroundMode, - saveMessagesImmediately, - sendRequest, - setMessage, - ], - ); + const messagesWithLoading = [...newMessages, loadingMessage]; + setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); - const messageActions = useMessageActions( - message, - setMessage, - onMessageSend, - saveMessagesImmediately, - ); + return messagesWithLoading; + }); + } const toggleReasoningExpansion = useCallback( (messageId) => { setMessage((prevMessages) => - prevMessages.map((item) => - item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT - ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } - : item, + prevMessages.map((msg) => + msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT + ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded } + : msg, ), ); }, @@ -442,12 +292,12 @@ const Playground = () => { ); const renderCustomChatContent = useCallback( - ({ message: currentMessage, className }) => { - const isCurrentlyEditing = editingMessageId === currentMessage.id; + ({ message, className }) => { + const isCurrentlyEditing = editingMessageId === message.id; return ( { ); }, [ - editValue, + styleState, editingMessageId, - handleEditCancel, + editValue, handleEditSave, + handleEditCancel, setEditValue, - styleState, toggleReasoningExpansion, ], ); @@ -474,7 +324,7 @@ const Playground = () => { (props) => { const { message: currentMessage } = props; const isAnyMessageGenerating = message.some( - (item) => item.status === 'loading' || item.status === 'incomplete', + (msg) => msg.status === 'loading' || msg.status === 'incomplete', ); const isCurrentlyEditing = editingMessageId === currentMessage.id; @@ -492,7 +342,7 @@ const Playground = () => { /> ); }, - [editingMessageId, handleMessageEdit, message, messageActions, styleState], + [messageActions, styleState, message, editingMessageId, handleMessageEdit], ); useEffect(() => { @@ -522,41 +372,41 @@ const Playground = () => { return () => clearTimeout(timer); }, [ - constructPreviewPayload, - customRequestBody, - customRequestMode, - inputs, message, + inputs, parameterEnabled, - setDebugData, + customRequestMode, + customRequestBody, + constructPreviewPayload, setPreviewPayload, + setDebugData, ]); useEffect(() => { debouncedSaveConfig(); }, [ - customRequestBody, - customRequestMode, - debouncedSaveConfig, inputs, parameterEnabled, - playgroundMode, showDebugPanel, + customRequestMode, + customRequestBody, + debouncedSaveConfig, ]); const handleClearMessages = useCallback(() => { setMessage([]); setTimeout(() => saveMessagesImmediately([]), 0); - }, [saveMessagesImmediately, setMessage]); + }, [setMessage, saveMessagesImmediately]); const handlePasteImage = useCallback( (base64Data) => { if (!inputs.imageEnabled) { return; } - handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); + const newUrls = [...(inputs.imageUrls || []), base64Data]; + handleInputChange('imageUrls', newUrls); }, - [handleInputChange, inputs.imageEnabled, inputs.imageUrls], + [inputs.imageEnabled, inputs.imageUrls, handleInputChange], ); const playgroundContextValue = { @@ -572,13 +422,13 @@ const Playground = () => { {(showSettings || !isMobile) && ( { showDebugPanel={showDebugPanel} customRequestMode={customRequestMode} customRequestBody={customRequestBody} - playgroundMode={playgroundMode} - modeHasAvailableModels={modeHasAvailableModels} onInputChange={handleInputChange} onParameterToggle={handleParameterToggle} onCloseSettings={() => setShowSettings(false)} @@ -607,77 +455,38 @@ const Playground = () => { )} -
-
- +
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} />
- {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( -
- -
-
- -
-
- - {activeModeUi.unavailableTitle} - - - {activeModeUi.unavailableDescription} - -
-
-
+ {showDebugPanel && !isMobile && ( +
+
)} - -
-
-
- setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} - title={activeModeUi.title} - subtitle={activeModeUi.subtitle} - placeholder={activeModeUi.placeholder} - /> -
- - {showDebugPanel && !isMobile && ( -
- -
- )} -
-
{showDebugPanel && isMobile && ( From f8a210c2dbca346f62937a522b7b8f1df1abfd8a Mon Sep 17 00:00:00 2001 From: link87ss Date: Sun, 29 Mar 2026 13:49:50 +0800 Subject: [PATCH 032/282] Revert "feat: separate creation center from playground" This reverts commit fb1bea09585b94131b587526131f7a52d8f130c4. --- web/src/App.jsx | 9 - web/src/components/layout/SiderBar.jsx | 6 - web/src/helpers/render.jsx | 3 - web/src/hooks/common/useSidebar.js | 1 - .../hooks/playground/usePlaygroundState.js | 1 - web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/fr.json | 8 +- web/src/i18n/locales/ja.json | 8 +- web/src/i18n/locales/ru.json | 8 +- web/src/i18n/locales/vi.json | 8 +- web/src/i18n/locales/zh-CN.json | 8 +- web/src/i18n/locales/zh-TW.json | 8 +- web/src/pages/CreationCenter/index.jsx | 711 ------------------ web/src/pages/Playground/index.jsx | 475 ++++++++---- 14 files changed, 361 insertions(+), 901 deletions(-) delete mode 100644 web/src/pages/CreationCenter/index.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index 90febf7088a9..a5d1ebc00b32 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -43,7 +43,6 @@ import Pricing from './pages/Pricing'; import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; -import CreationCenter from './pages/CreationCenter'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; @@ -148,14 +147,6 @@ function App() { } /> - - - - } - /> {} }) => { const chatMenuItems = useMemo(() => { const items = [ - { - text: t('创作中心'), - itemKey: 'creation', - to: '/creation', - }, { text: t('操练场'), itemKey: 'playground', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index e0d8d8d4bef0..28da657f472e 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -60,7 +60,6 @@ import { import { LayoutDashboard, - Sparkles, TerminalSquare, MessageSquare, Key, @@ -121,8 +120,6 @@ export function getLucideIcon(key, selected = false) { return ; case 'playground': return ; - case 'creation': - return ; case 'chat': return ; case 'token': diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 68a3f9c9709c..cd74ada20280 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -28,7 +28,6 @@ const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; export const DEFAULT_ADMIN_CONFIG = { chat: { enabled: true, - creation: true, playground: true, chat: true, }, diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index bf7643da5531..fb137db23d60 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -167,7 +167,6 @@ export const usePlaygroundState = () => { customRequestMode, customRequestBody, playgroundMode, - playgroundMode, ]); // 配置导入/重置 diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d6912bf913da..bc1d6f7d4388 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3350,8 +3350,8 @@ "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching." , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3364,8 +3364,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index cc593168e42f..15036268f0b2 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3311,8 +3311,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3325,8 +3325,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 6f239a74db62..954c5b50ca47 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3292,8 +3292,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3306,8 +3306,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index bbb34040376c..9c8512560f11 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3325,8 +3325,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3339,8 +3339,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 90e334666b47..3d25ece00f48 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3862,8 +3862,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens" , "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between smart chat, image creation, and video creation inside the creation center", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The creation center keeps reusing the existing model and request flow. Choose a task type here and the system will align the most suitable model and settings first.", + "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", "当前模型": "Current model", "未选择模型": "No model selected", "智能对话": "Smart Chat", @@ -3876,8 +3876,8 @@ "当前模式": "Current mode", "切换到此模式": "Switch to this mode", "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the current creation workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing video generation flow.", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", "智能对话工作区": "Smart Chat Workspace", "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index e32ab9211b3a..f47286e36607 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2959,8 +2959,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" , "创作中心": "创作中心", - "在一个操练场里切换对话、图片和视频创作": "在创作中心切换智能对话、图片创作和视频创作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "创作中心会继续复用现有模型与请求链路,你只需要选择任务类型,系统会优先为你对齐合适的模型与配置。", + "在一个操练场里切换对话、图片和视频创作": "在一个操练场里切换对话、图片和视频创作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。", "当前模型": "当前模型", "未选择模型": "未选择模型", "智能对话": "智能对话", @@ -2973,8 +2973,8 @@ "当前模式": "当前模式", "切换到此模式": "切换到此模式", "围绕系统提示词、多轮消息和流式响应来组织创作。": "围绕系统提示词、多轮消息和流式响应来组织创作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让当前创作工作区更适合图像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用现有视频生成链路。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。", "智能对话工作区": "智能对话工作区", "输入你的问题、任务或提示词方向...": "输入你的问题、任务或提示词方向...", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 185633d37439..8babb2ad0487 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2976,8 +2976,8 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens" , "创作中心": "創作中心", - "在一个操练场里切换对话、图片和视频创作": "在創作中心切換智慧對話、圖片創作與影片創作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "創作中心會繼續沿用現有模型與請求鏈路,你只需要選擇任務類型,系統會優先幫你對齊合適的模型與配置。", + "在一个操练场里切换对话、图片和视频创作": "在一個操練場裡切換對話、圖片和影片創作", + "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作區會繼續沿用現有操練場能力,你只需要在這裡選擇創作模式,系統會優先幫你對齊合適的模型與配置。", "当前模型": "目前模型", "未选择模型": "尚未選擇模型", "智能对话": "智慧對話", @@ -2990,8 +2990,8 @@ "当前模式": "目前模式", "切换到此模式": "切換到此模式", "围绕系统提示词、多轮消息和流式响应来组织创作。": "圍繞系統提示詞、多輪訊息與串流回應來組織創作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓當前創作工作區更適合圖像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用現有的影片生成鏈路。", + "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓同一套操練場工作區更適合圖像生成。", + "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用操練場現有的影片生成鏈路。", "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "目前帳號下暫無適合此創作模式的模型,可切換模式或保留完整模型列表查看。", "智能对话工作区": "智慧對話工作區", "输入你的问题、任务或提示词方向...": "輸入你的問題、任務或提示詞方向...", diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx deleted file mode 100644 index 6e7e4acbbc1c..000000000000 --- a/web/src/pages/CreationCenter/index.jsx +++ /dev/null @@ -1,711 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useCallback, useContext, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; -import { AlertTriangle } from 'lucide-react'; -import { UserContext } from '../../context/User'; -import { useIsMobile } from '../../hooks/common/useIsMobile'; -import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; -import { useMessageActions } from '../../hooks/playground/useMessageActions'; -import { useApiRequest } from '../../hooks/playground/useApiRequest'; -import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; -import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; -import { useDataLoader } from '../../hooks/playground/useDataLoader'; -import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; -import { - buildApiPayload, - buildMessageContent, - createLoadingAssistantMessage, - createMessage, - encodeToBase64, - getAvailableModelsForPlaygroundMode, - getLogo, - getPreferredModelForPlaygroundMode, - getTextContent, - isModelCompatibleWithPlaygroundMode, - PLAYGROUND_MODES, - stringToColor, -} from '../../helpers'; -import { - OptimizedDebugPanel, - OptimizedMessageActions, - OptimizedMessageContent, - OptimizedSettingsPanel, -} from '../../components/playground/OptimizedComponents'; -import ChatArea from '../../components/playground/ChatArea'; -import FloatingButtons from '../../components/playground/FloatingButtons'; -import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; -import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; - -const generateAvatarDataUrl = (username) => { - if (!username) { - return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'; - } - const firstLetter = username[0].toUpperCase(); - const bgColor = stringToColor(username); - const svg = ` - - - ${firstLetter} - - `; - return `data:image/svg+xml;base64,${encodeToBase64(svg)}`; -}; - -const CreationCenter = () => { - const { t } = useTranslation(); - const [userState] = useContext(UserContext); - const isMobile = useIsMobile(); - const styleState = { isMobile }; - const [searchParams] = useSearchParams(); - - const state = usePlaygroundState(); - const { - inputs, - parameterEnabled, - showDebugPanel, - customRequestMode, - customRequestBody, - playgroundMode, - showSettings, - models, - groups, - message, - debugData, - activeDebugTab, - previewPayload, - sseSourceRef, - chatRef, - handleInputChange, - handleParameterToggle, - debouncedSaveConfig, - saveMessagesImmediately, - handleConfigImport, - handleConfigReset, - setShowSettings, - setModels, - setGroups, - setMessage, - setDebugData, - setActiveDebugTab, - setPreviewPayload, - setShowDebugPanel, - setCustomRequestMode, - setCustomRequestBody, - setPlaygroundMode, - } = state; - - const { sendRequest, onStopGenerator } = useApiRequest( - setMessage, - setDebugData, - setActiveDebugTab, - sseSourceRef, - saveMessagesImmediately, - ); - - useDataLoader(userState, inputs, handleInputChange, setModels, setGroups); - - const { - editingMessageId, - editValue, - setEditValue, - handleMessageEdit, - handleEditSave, - handleEditCancel, - } = useMessageEdit( - setMessage, - inputs, - parameterEnabled, - sendRequest, - saveMessagesImmediately, - ); - - const { syncMessageToCustomBody, syncCustomBodyToMessage } = - useSyncMessageAndCustomBody( - customRequestMode, - customRequestBody, - message, - inputs, - setCustomRequestBody, - setMessage, - debouncedSaveConfig, - ); - - const roleInfo = { - user: { - name: userState?.user?.username || 'User', - avatar: generateAvatarDataUrl(userState?.user?.username), - }, - assistant: { - name: 'Assistant', - avatar: getLogo(), - }, - system: { - name: 'System', - avatar: getLogo(), - }, - }; - - const availableModeModels = { - [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.CHAT, - ), - [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.IMAGE, - ), - [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.VIDEO, - ), - }; - const modelsLoaded = models.length > 0; - const modeCounts = { - [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, - [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, - [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, - }; - const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; - - const modeUi = { - [PLAYGROUND_MODES.CHAT]: { - title: t('智能对话工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('选择模型开始创作'), - placeholder: t('输入你的问题、任务或提示词方向...'), - unavailableTitle: t('当前账号暂无适合智能对话的模型'), - unavailableDescription: t( - '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', - ), - }, - [PLAYGROUND_MODES.IMAGE]: { - title: t('图片创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生图与带图编辑能力'), - placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), - unavailableTitle: t('当前账号暂无适合图片创作的模型'), - unavailableDescription: t( - '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', - ), - }, - [PLAYGROUND_MODES.VIDEO]: { - title: t('视频创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生视频与参考图视频能力'), - placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), - unavailableTitle: t('当前账号暂无适合视频创作的模型'), - unavailableDescription: t( - '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', - ), - }, - }; - const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; - - const constructPreviewPayload = useCallback(() => { - try { - if (customRequestMode && customRequestBody && customRequestBody.trim()) { - try { - return JSON.parse(customRequestBody); - } catch (parseError) { - console.warn('Failed to parse custom request body for preview:', parseError); - } - } - - const messages = [...message]; - if ( - !( - messages.length === 0 || - messages.every((item) => item.role !== MESSAGE_ROLES.USER) - ) - ) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index].role === MESSAGE_ROLES.USER) { - if (inputs.imageEnabled && inputs.imageUrls) { - const validImageUrls = inputs.imageUrls.filter( - (url) => url.trim() !== '', - ); - if (validImageUrls.length > 0) { - const textContent = getTextContent(messages[index]) || '示例消息'; - messages[index] = { - ...messages[index], - content: buildMessageContent(textContent, validImageUrls, true), - }; - } - } - break; - } - } - } - - return buildApiPayload(messages, null, inputs, parameterEnabled); - } catch (error) { - console.error('Failed to construct preview payload:', error); - return null; - } - }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); - - const handleModeChange = useCallback( - (nextMode) => { - setPlaygroundMode(nextMode); - if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } - - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - nextMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, - [ - handleInputChange, - inputs.imageEnabled, - inputs.model, - models, - setPlaygroundMode, - ], - ); - - useEffect(() => { - if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } - }, [handleInputChange, inputs.imageEnabled, playgroundMode]); - - useEffect(() => { - if (!modelsLoaded) { - return; - } - - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); - - const onMessageSend = useCallback( - (content, attachment) => { - console.log('attachment: ', attachment); - - if (!customRequestMode) { - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const resolvedModel = preferredModel || inputs.model; - - if (!modeHasAvailableModels || !resolvedModel) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } - - if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } - - if (resolvedModel !== inputs.model) { - handleInputChange('model', resolvedModel); - } - } - - const userMessage = createMessage(MESSAGE_ROLES.USER, content); - const loadingMessage = createLoadingAssistantMessage(); - - if (customRequestMode && customRequestBody) { - try { - const customPayload = JSON.parse(customRequestBody); - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessage, loadingMessage]; - sendRequest(customPayload, customPayload.stream !== false); - setTimeout(() => saveMessagesImmediately(newMessages), 0); - return newMessages; - }); - return; - } catch (error) { - console.error('Failed to parse custom request body:', error); - Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); - return; - } - } - - const validImageUrls = (inputs.imageUrls || []).filter( - (url) => url.trim() !== '', - ); - const messageContent = buildMessageContent( - content, - validImageUrls, - inputs.imageEnabled, - ); - const userMessageWithImages = createMessage( - MESSAGE_ROLES.USER, - messageContent, - ); - - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const requestInputs = - preferredModel && preferredModel !== inputs.model - ? { ...inputs, model: preferredModel } - : inputs; - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessageWithImages]; - const payload = buildApiPayload( - newMessages, - null, - requestInputs, - parameterEnabled, - ); - sendRequest(payload, requestInputs.stream); - - if (inputs.imageEnabled) { - setTimeout(() => { - handleInputChange('imageEnabled', false); - }, 100); - } - - const messagesWithLoading = [...newMessages, loadingMessage]; - setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); - return messagesWithLoading; - }); - }, - [ - activeModeUi.unavailableTitle, - customRequestBody, - customRequestMode, - handleInputChange, - inputs, - modeHasAvailableModels, - models, - parameterEnabled, - playgroundMode, - saveMessagesImmediately, - sendRequest, - setMessage, - ], - ); - - const messageActions = useMessageActions( - message, - setMessage, - onMessageSend, - saveMessagesImmediately, - ); - - const toggleReasoningExpansion = useCallback( - (messageId) => { - setMessage((prevMessages) => - prevMessages.map((item) => - item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT - ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } - : item, - ), - ); - }, - [setMessage], - ); - - const renderCustomChatContent = useCallback( - ({ message: currentMessage, className }) => { - const isCurrentlyEditing = editingMessageId === currentMessage.id; - - return ( - - ); - }, - [ - editValue, - editingMessageId, - handleEditCancel, - handleEditSave, - setEditValue, - styleState, - toggleReasoningExpansion, - ], - ); - - const renderChatBoxAction = useCallback( - (props) => { - const { message: currentMessage } = props; - const isAnyMessageGenerating = message.some( - (item) => item.status === 'loading' || item.status === 'incomplete', - ); - const isCurrentlyEditing = editingMessageId === currentMessage.id; - - return ( - - ); - }, - [editingMessageId, handleMessageEdit, message, messageActions, styleState], - ); - - useEffect(() => { - syncMessageToCustomBody(); - }, [message, syncMessageToCustomBody]); - - useEffect(() => { - syncCustomBodyToMessage(); - }, [customRequestBody, syncCustomBodyToMessage]); - - useEffect(() => { - if (searchParams.get('expired')) { - Toast.warning(t('登录过期,请重新登录!')); - } - }, [searchParams, t]); - - useEffect(() => { - const timer = setTimeout(() => { - const preview = constructPreviewPayload(); - setPreviewPayload(preview); - setDebugData((prev) => ({ - ...prev, - previewRequest: preview ? JSON.stringify(preview, null, 2) : null, - previewTimestamp: preview ? new Date().toISOString() : null, - })); - }, 300); - - return () => clearTimeout(timer); - }, [ - constructPreviewPayload, - customRequestBody, - customRequestMode, - inputs, - message, - parameterEnabled, - setDebugData, - setPreviewPayload, - ]); - - useEffect(() => { - debouncedSaveConfig(); - }, [ - customRequestBody, - customRequestMode, - debouncedSaveConfig, - inputs, - parameterEnabled, - playgroundMode, - showDebugPanel, - ]); - - const handleClearMessages = useCallback(() => { - setMessage([]); - setTimeout(() => saveMessagesImmediately([]), 0); - }, [saveMessagesImmediately, setMessage]); - - const handlePasteImage = useCallback( - (base64Data) => { - if (!inputs.imageEnabled) { - return; - } - handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); - }, - [handleInputChange, inputs.imageEnabled, inputs.imageUrls], - ); - - const playgroundContextValue = { - onPasteImage: handlePasteImage, - imageUrls: inputs.imageUrls || [], - imageEnabled: inputs.imageEnabled || false, - }; - - return ( - -
- - {(showSettings || !isMobile) && ( - - setShowSettings(false)} - onConfigImport={handleConfigImport} - onConfigReset={handleConfigReset} - onCustomRequestModeChange={setCustomRequestMode} - onCustomRequestBodyChange={setCustomRequestBody} - previewPayload={previewPayload} - messages={message} - /> - - )} - - -
-
- -
- - {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( -
- -
-
- -
-
- - {activeModeUi.unavailableTitle} - - - {activeModeUi.unavailableDescription} - -
-
-
-
- )} - -
-
-
- setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} - title={activeModeUi.title} - subtitle={activeModeUi.subtitle} - placeholder={activeModeUi.placeholder} - /> -
- - {showDebugPanel && !isMobile && ( -
- -
- )} -
-
-
- - {showDebugPanel && isMobile && ( -
- setShowDebugPanel(false)} - customRequestMode={customRequestMode} - /> -
- )} - - setShowSettings(!showSettings)} - onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} - /> -
-
-
-
- ); -}; - -export default CreationCenter; diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx index 788c17bdddd7..d8d9f4b9e002 100644 --- a/web/src/pages/Playground/index.jsx +++ b/web/src/pages/Playground/index.jsx @@ -17,11 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useCallback, useRef } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; - +import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; +import { AlertTriangle } from 'lucide-react'; import { UserContext } from '../../context/User'; import { useIsMobile } from '../../hooks/common/useIsMobile'; import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; @@ -30,28 +30,30 @@ import { useApiRequest } from '../../hooks/playground/useApiRequest'; import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; import { useDataLoader } from '../../hooks/playground/useDataLoader'; +import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; import { - MESSAGE_ROLES, - ERROR_MESSAGES, -} from '../../constants/playground.constants'; -import { - getLogo, - stringToColor, + buildApiPayload, buildMessageContent, - createMessage, createLoadingAssistantMessage, - getTextContent, - buildApiPayload, + createMessage, encodeToBase64, + getAvailableModelsForPlaygroundMode, + getLogo, + getPreferredModelForPlaygroundMode, + getTextContent, + isModelCompatibleWithPlaygroundMode, + PLAYGROUND_MODES, + stringToColor, } from '../../helpers'; import { - OptimizedSettingsPanel, OptimizedDebugPanel, - OptimizedMessageContent, OptimizedMessageActions, + OptimizedMessageContent, + OptimizedSettingsPanel, } from '../../components/playground/OptimizedComponents'; import ChatArea from '../../components/playground/ChatArea'; import FloatingButtons from '../../components/playground/FloatingButtons'; +import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; const generateAvatarDataUrl = (username) => { @@ -83,10 +85,10 @@ const Playground = () => { showDebugPanel, customRequestMode, customRequestBody, + playgroundMode, showSettings, models, groups, - status, message, debugData, activeDebugTab, @@ -102,7 +104,6 @@ const Playground = () => { setShowSettings, setModels, setGroups, - setStatus, setMessage, setDebugData, setActiveDebugTab, @@ -110,6 +111,7 @@ const Playground = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, + setPlaygroundMode, } = state; const { sendRequest, onStopGenerator } = useApiRequest( @@ -163,12 +165,64 @@ const Playground = () => { }, }; - const messageActions = useMessageActions( - message, - setMessage, - onMessageSend, - saveMessagesImmediately, - ); + const availableModeModels = { + [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.CHAT, + ), + [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.IMAGE, + ), + [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( + models, + PLAYGROUND_MODES.VIDEO, + ), + }; + const modelsLoaded = models.length > 0; + const modeCounts = { + [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, + [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, + [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, + }; + const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; + + const modeUi = { + [PLAYGROUND_MODES.CHAT]: { + title: t('智能对话工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('选择模型开始创作'), + placeholder: t('输入你的问题、任务或提示词方向...'), + unavailableTitle: t('当前账号暂无适合智能对话的模型'), + unavailableDescription: t( + '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', + ), + }, + [PLAYGROUND_MODES.IMAGE]: { + title: t('图片创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生图与带图编辑能力'), + placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), + unavailableTitle: t('当前账号暂无适合图片创作的模型'), + unavailableDescription: t( + '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', + ), + }, + [PLAYGROUND_MODES.VIDEO]: { + title: t('视频创作工作区'), + subtitle: inputs.model + ? `${t('当前模型')} · ${inputs.model}` + : t('可继续使用文生视频与参考图视频能力'), + placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), + unavailableTitle: t('当前账号暂无适合视频创作的模型'), + unavailableDescription: t( + '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', + ), + }, + }; + const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; const constructPreviewPayload = useCallback(() => { try { @@ -176,32 +230,29 @@ const Playground = () => { try { return JSON.parse(customRequestBody); } catch (parseError) { - console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError); + console.warn('Failed to parse custom request body for preview:', parseError); } } - let messages = [...message]; - + const messages = [...message]; if ( !( messages.length === 0 || - messages.every((msg) => msg.role !== MESSAGE_ROLES.USER) + messages.every((item) => item.role !== MESSAGE_ROLES.USER) ) ) { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === MESSAGE_ROLES.USER) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === MESSAGE_ROLES.USER) { if (inputs.imageEnabled && inputs.imageUrls) { const validImageUrls = inputs.imageUrls.filter( (url) => url.trim() !== '', ); if (validImageUrls.length > 0) { - const textContent = getTextContent(messages[i]) || '示例消息'; - const content = buildMessageContent( - textContent, - validImageUrls, - true, - ); - messages[i] = { ...messages[i], content }; + const textContent = getTextContent(messages[index]) || '示例消息'; + messages[index] = { + ...messages[index], + content: buildMessageContent(textContent, validImageUrls, true), + }; } } break; @@ -211,80 +262,179 @@ const Playground = () => { return buildApiPayload(messages, null, inputs, parameterEnabled); } catch (error) { - console.error('构造预览请求体失败:', error); + console.error('Failed to construct preview payload:', error); return null; } - }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]); + }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); - function onMessageSend(content, attachment) { - console.log('attachment: ', attachment); + const handleModeChange = useCallback( + (nextMode) => { + setPlaygroundMode(nextMode); + if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } - const userMessage = createMessage(MESSAGE_ROLES.USER, content); - const loadingMessage = createLoadingAssistantMessage(); + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + nextMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, + [ + handleInputChange, + inputs.imageEnabled, + inputs.model, + models, + setPlaygroundMode, + ], + ); - if (customRequestMode && customRequestBody) { - try { - const customPayload = JSON.parse(customRequestBody); + useEffect(() => { + if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { + handleInputChange('imageEnabled', false); + } + }, [handleInputChange, inputs.imageEnabled, playgroundMode]); - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessage, loadingMessage]; + useEffect(() => { + if (!modelsLoaded) { + return; + } - sendRequest(customPayload, customPayload.stream !== false); + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + if (preferredModel && preferredModel !== inputs.model) { + handleInputChange('model', preferredModel); + } + }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); + + const onMessageSend = useCallback( + (content, attachment) => { + console.log('attachment: ', attachment); + + if (!customRequestMode) { + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const resolvedModel = preferredModel || inputs.model; + + if (!modeHasAvailableModels || !resolvedModel) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } - setTimeout(() => saveMessagesImmediately(newMessages), 0); + if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { + Toast.warning(activeModeUi.unavailableTitle); + return; + } - return newMessages; - }); - return; - } catch (error) { - console.error('自定义请求体JSON解析失败:', error); - Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); - return; + if (resolvedModel !== inputs.model) { + handleInputChange('model', resolvedModel); + } } - } - const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== ''); - const messageContent = buildMessageContent( - content, - validImageUrls, - inputs.imageEnabled, - ); - const userMessageWithImages = createMessage( - MESSAGE_ROLES.USER, - messageContent, - ); + const userMessage = createMessage(MESSAGE_ROLES.USER, content); + const loadingMessage = createLoadingAssistantMessage(); - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessageWithImages]; + if (customRequestMode && customRequestBody) { + try { + const customPayload = JSON.parse(customRequestBody); + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessage, loadingMessage]; + sendRequest(customPayload, customPayload.stream !== false); + setTimeout(() => saveMessagesImmediately(newMessages), 0); + return newMessages; + }); + return; + } catch (error) { + console.error('Failed to parse custom request body:', error); + Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); + return; + } + } - const payload = buildApiPayload( - newMessages, - null, - inputs, - parameterEnabled, + const validImageUrls = (inputs.imageUrls || []).filter( + (url) => url.trim() !== '', + ); + const messageContent = buildMessageContent( + content, + validImageUrls, + inputs.imageEnabled, + ); + const userMessageWithImages = createMessage( + MESSAGE_ROLES.USER, + messageContent, ); - sendRequest(payload, inputs.stream); - if (inputs.imageEnabled) { - setTimeout(() => { - handleInputChange('imageEnabled', false); - }, 100); - } + const preferredModel = getPreferredModelForPlaygroundMode( + inputs.model, + models, + playgroundMode, + ); + const requestInputs = + preferredModel && preferredModel !== inputs.model + ? { ...inputs, model: preferredModel } + : inputs; + + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessageWithImages]; + const payload = buildApiPayload( + newMessages, + null, + requestInputs, + parameterEnabled, + ); + sendRequest(payload, requestInputs.stream); + + if (inputs.imageEnabled) { + setTimeout(() => { + handleInputChange('imageEnabled', false); + }, 100); + } - const messagesWithLoading = [...newMessages, loadingMessage]; - setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); + const messagesWithLoading = [...newMessages, loadingMessage]; + setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); + return messagesWithLoading; + }); + }, + [ + activeModeUi.unavailableTitle, + customRequestBody, + customRequestMode, + handleInputChange, + inputs, + modeHasAvailableModels, + models, + parameterEnabled, + playgroundMode, + saveMessagesImmediately, + sendRequest, + setMessage, + ], + ); - return messagesWithLoading; - }); - } + const messageActions = useMessageActions( + message, + setMessage, + onMessageSend, + saveMessagesImmediately, + ); const toggleReasoningExpansion = useCallback( (messageId) => { setMessage((prevMessages) => - prevMessages.map((msg) => - msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT - ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded } - : msg, + prevMessages.map((item) => + item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT + ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } + : item, ), ); }, @@ -292,12 +442,12 @@ const Playground = () => { ); const renderCustomChatContent = useCallback( - ({ message, className }) => { - const isCurrentlyEditing = editingMessageId === message.id; + ({ message: currentMessage, className }) => { + const isCurrentlyEditing = editingMessageId === currentMessage.id; return ( { ); }, [ - styleState, - editingMessageId, editValue, - handleEditSave, + editingMessageId, handleEditCancel, + handleEditSave, setEditValue, + styleState, toggleReasoningExpansion, ], ); @@ -324,7 +474,7 @@ const Playground = () => { (props) => { const { message: currentMessage } = props; const isAnyMessageGenerating = message.some( - (msg) => msg.status === 'loading' || msg.status === 'incomplete', + (item) => item.status === 'loading' || item.status === 'incomplete', ); const isCurrentlyEditing = editingMessageId === currentMessage.id; @@ -342,7 +492,7 @@ const Playground = () => { /> ); }, - [messageActions, styleState, message, editingMessageId, handleMessageEdit], + [editingMessageId, handleMessageEdit, message, messageActions, styleState], ); useEffect(() => { @@ -372,41 +522,41 @@ const Playground = () => { return () => clearTimeout(timer); }, [ - message, + constructPreviewPayload, + customRequestBody, + customRequestMode, inputs, + message, parameterEnabled, - customRequestMode, - customRequestBody, - constructPreviewPayload, - setPreviewPayload, setDebugData, + setPreviewPayload, ]); useEffect(() => { debouncedSaveConfig(); }, [ + customRequestBody, + customRequestMode, + debouncedSaveConfig, inputs, parameterEnabled, + playgroundMode, showDebugPanel, - customRequestMode, - customRequestBody, - debouncedSaveConfig, ]); const handleClearMessages = useCallback(() => { setMessage([]); setTimeout(() => saveMessagesImmediately([]), 0); - }, [setMessage, saveMessagesImmediately]); + }, [saveMessagesImmediately, setMessage]); const handlePasteImage = useCallback( (base64Data) => { if (!inputs.imageEnabled) { return; } - const newUrls = [...(inputs.imageUrls || []), base64Data]; - handleInputChange('imageUrls', newUrls); + handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); }, - [inputs.imageEnabled, inputs.imageUrls, handleInputChange], + [handleInputChange, inputs.imageEnabled, inputs.imageUrls], ); const playgroundContextValue = { @@ -422,13 +572,13 @@ const Playground = () => { {(showSettings || !isMobile) && ( { showDebugPanel={showDebugPanel} customRequestMode={customRequestMode} customRequestBody={customRequestBody} + playgroundMode={playgroundMode} + modeHasAvailableModels={modeHasAvailableModels} onInputChange={handleInputChange} onParameterToggle={handleParameterToggle} onCloseSettings={() => setShowSettings(false)} @@ -455,38 +607,77 @@ const Playground = () => { )} -
-
- setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} +
+
+
- {showDebugPanel && !isMobile && ( -
- + {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( +
+ +
+
+ +
+
+ + {activeModeUi.unavailableTitle} + + + {activeModeUi.unavailableDescription} + +
+
+
)} + +
+
+
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} + title={activeModeUi.title} + subtitle={activeModeUi.subtitle} + placeholder={activeModeUi.placeholder} + /> +
+ + {showDebugPanel && !isMobile && ( +
+ +
+ )} +
+
{showDebugPanel && isMobile && ( From cdb0895278b4d29b9e66cb06457f61b89e804712 Mon Sep 17 00:00:00 2001 From: link87ss Date: Sun, 29 Mar 2026 13:49:51 +0800 Subject: [PATCH 033/282] Revert "feat: add playground creation center" This reverts commit 0277e885d6f02aadc9248afe62d73b21d10b2693. --- web/src/components/playground/ChatArea.jsx | 56 +- .../playground/OptimizedComponents.js | 2 - .../playground/PlaygroundCreationCenter.jsx | 159 ------ .../components/playground/SettingsPanel.jsx | 202 +++---- .../components/playground/configStorage.js | 2 - web/src/constants/playground.constants.js | 7 - web/src/helpers/api.js | 64 ++- web/src/helpers/index.js | 1 - web/src/helpers/playgroundMode.js | 108 ---- web/src/hooks/playground/useApiRequest.jsx | 28 +- .../hooks/playground/usePlaygroundState.js | 10 - web/src/i18n/locales/en.json | 33 -- web/src/i18n/locales/fr.json | 33 -- web/src/i18n/locales/ja.json | 33 -- web/src/i18n/locales/ru.json | 33 -- web/src/i18n/locales/vi.json | 33 -- web/src/i18n/locales/zh-CN.json | 33 -- web/src/i18n/locales/zh-TW.json | 33 -- web/src/pages/Playground/index.jsx | 520 +++++++----------- 19 files changed, 357 insertions(+), 1033 deletions(-) delete mode 100644 web/src/components/playground/PlaygroundCreationCenter.jsx delete mode 100644 web/src/helpers/playgroundMode.js diff --git a/web/src/components/playground/ChatArea.jsx b/web/src/components/playground/ChatArea.jsx index ca0f60179cfe..2c65731f5487 100644 --- a/web/src/components/playground/ChatArea.jsx +++ b/web/src/components/playground/ChatArea.jsx @@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Button, Card, Chat, Typography } from '@douyinfe/semi-ui'; -import { Eye, EyeOff, MessageSquare } from 'lucide-react'; +import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui'; +import { MessageSquare, Eye, EyeOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import CustomInputRender from './CustomInputRender'; const ChatArea = ({ chatRef, message, + inputs, styleState, showDebugPanel, roleInfo, @@ -38,9 +39,6 @@ const ChatArea = ({ onToggleDebugPanel, renderCustomChatContent, renderChatBoxAction, - title, - subtitle, - placeholder, }) => { const { t } = useTranslation(); @@ -50,48 +48,52 @@ const ChatArea = ({ return ( + {/* 聊天头部 */} {styleState.isMobile ? ( -
+
) : ( -
-
-
-
- +
+
+
+
+
-
+
- {title || t('AI 对话')} + {t('AI 对话')} - {subtitle || t('选择模型开始创作')} + {inputs.model || t('选择模型开始对话')}
- +
+ +
)} + {/* 聊天内容区域 */}
diff --git a/web/src/components/playground/OptimizedComponents.js b/web/src/components/playground/OptimizedComponents.js index 1ef86b960227..ff679c6591c8 100644 --- a/web/src/components/playground/OptimizedComponents.js +++ b/web/src/components/playground/OptimizedComponents.js @@ -70,8 +70,6 @@ export const OptimizedSettingsPanel = React.memo( JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) && prevProps.customRequestMode === nextProps.customRequestMode && prevProps.customRequestBody === nextProps.customRequestBody && - prevProps.playgroundMode === nextProps.playgroundMode && - prevProps.modeHasAvailableModels === nextProps.modeHasAvailableModels && prevProps.showDebugPanel === nextProps.showDebugPanel && prevProps.showSettings === nextProps.showSettings && JSON.stringify(prevProps.previewPayload) === diff --git a/web/src/components/playground/PlaygroundCreationCenter.jsx b/web/src/components/playground/PlaygroundCreationCenter.jsx deleted file mode 100644 index e2eba51b3746..000000000000 --- a/web/src/components/playground/PlaygroundCreationCenter.jsx +++ /dev/null @@ -1,159 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React from 'react'; -import { Card, Typography } from '@douyinfe/semi-ui'; -import { Clapperboard, ImagePlus, MessageSquareText, Sparkles } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { PLAYGROUND_MODES } from '../../helpers'; - -const MODE_CARDS = { - [PLAYGROUND_MODES.CHAT]: { - icon: MessageSquareText, - titleKey: '智能对话', - descriptionKey: '适合长上下文、多轮追问、提示词调试和结构化输出。', - accent: 'from-sky-500 via-cyan-400 to-blue-500', - }, - [PLAYGROUND_MODES.IMAGE]: { - icon: ImagePlus, - titleKey: '图片创作', - descriptionKey: '围绕提示词、尺寸比例和参考图来生成或编辑图像。', - accent: 'from-amber-500 via-orange-400 to-rose-400', - }, - [PLAYGROUND_MODES.VIDEO]: { - icon: Clapperboard, - titleKey: '视频创作', - descriptionKey: '聚焦时长、清晰度和参考模式,快速组织视频生成任务。', - accent: 'from-fuchsia-500 via-rose-500 to-orange-400', - }, -}; - -const PlaygroundCreationCenter = ({ - playgroundMode, - onModeChange, - modeCounts, - currentModel, -}) => { - const { t } = useTranslation(); - - return ( - -
-
-
-
-
-
- - {t('创作中心')} -
- - {t('在一个操练场里切换对话、图片和视频创作')} - - - {t('下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。')} - -
-
-
- {t('当前模型')} -
-
- {currentModel || t('未选择模型')} -
-
-
- -
- {Object.entries(MODE_CARDS).map(([mode, config]) => { - const Icon = config.icon; - const isActive = playgroundMode === mode; - const count = modeCounts?.[mode] || 0; - - return ( - - ); - })} -
-
-
- - ); -}; - -export default PlaygroundCreationCenter; diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index baa00a4121f6..57b780d660ab 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -18,67 +18,14 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Button, Card, Select, Switch, Typography } from '@douyinfe/semi-ui'; -import { - Clapperboard, - ImagePlus, - MessageSquareText, - Settings, - Sparkles, - ToggleLeft, - Users, - X, -} from 'lucide-react'; +import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui'; +import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { - isAdobeImage4KModel, - isAdobeImageModel, - isAdobeSoraModel, - isAdobeVeoModel, - isAdobeVideoModel, - isGrokImagineImageModel, - isGrokImagineVideoModel, - isVideoModeModel, - PLAYGROUND_MODES, - renderGroupOption, - selectFilter, -} from '../../helpers'; +import { renderGroupOption, selectFilter } from '../../helpers'; +import ParameterControl from './ParameterControl'; +import ImageUrlInput from './ImageUrlInput'; import ConfigManager from './ConfigManager'; import CustomRequestEditor from './CustomRequestEditor'; -import ImageUrlInput from './ImageUrlInput'; -import ParameterControl from './ParameterControl'; - -const MODE_SUMMARY_STYLES = { - [PLAYGROUND_MODES.CHAT]: { - icon: MessageSquareText, - titleKey: '智能对话', - descriptionKey: '围绕系统提示词、多轮消息和流式响应来组织创作。', - accent: 'from-sky-500 to-cyan-400', - }, - [PLAYGROUND_MODES.IMAGE]: { - icon: ImagePlus, - titleKey: '图片创作', - descriptionKey: - '优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。', - accent: 'from-amber-500 to-orange-400', - }, - [PLAYGROUND_MODES.VIDEO]: { - icon: Clapperboard, - titleKey: '视频创作', - descriptionKey: '聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。', - accent: 'from-rose-500 to-fuchsia-500', - }, -}; - -const normalizeGrokImageSize = (size) => { - if (size === '1536x1024') { - return '1792x1024'; - } - if (size === '1024x1536') { - return '1024x1792'; - } - return size; -}; const SettingsPanel = ({ inputs, @@ -98,23 +45,51 @@ const SettingsPanel = ({ onCustomRequestBodyChange, previewPayload, messages, - playgroundMode, - modeHasAvailableModels, }) => { const { t } = useTranslation(); - const modeSummary = - MODE_SUMMARY_STYLES[playgroundMode] || MODE_SUMMARY_STYLES[PLAYGROUND_MODES.CHAT]; - const ModeIcon = modeSummary.icon; - - const isCurrentGrokImagineImageModel = isGrokImagineImageModel(inputs.model); - const isCurrentAdobeImageModel = isAdobeImageModel(inputs.model); - const isCurrentAdobeVideoModel = isAdobeVideoModel(inputs.model); - const isCurrentAdobeImage4KModel = isAdobeImage4KModel(inputs.model); - const isCurrentAdobeSoraModel = isAdobeSoraModel(inputs.model); - const isCurrentAdobeVeoModel = isAdobeVeoModel(inputs.model); - const isCurrentVideoModel = isVideoModeModel(inputs.model); - const isCurrentGrokImagineVideoModel = isGrokImagineVideoModel(inputs.model); - + const normalizeGrokImageSize = (size) => { + if (size === '1536x1024') { + return '1792x1024'; + } + if (size === '1024x1536') { + return '1024x1792'; + } + return size; + }; + const grokImagineImageModels = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', + 'grok-imagine-1.0-edit', + ]); + const adobeImageModels = new Set([ + 'nano-banana', + 'nano-banana-4k', + 'nano-banana2', + 'nano-banana2-4k', + 'nano-banana-pro', + 'nano-banana-pro-4k', + ]); + const adobeVideoModels = new Set([ + 'sora2', + 'sora2-pro', + 'veo31', + 'veo31-ref', + 'veo31-fast', + ]); + const isGrokImagineImageModel = grokImagineImageModels.has(inputs.model); + const isAdobeImageModel = adobeImageModels.has(inputs.model); + const isAdobeVideoModel = adobeVideoModels.has(inputs.model); + const isAdobeImage4KModel = + typeof inputs.model === 'string' && inputs.model.endsWith('-4k'); + const isAdobeSoraModel = + inputs.model === 'sora2' || inputs.model === 'sora2-pro'; + const isAdobeVeoModel = + inputs.model === 'veo31' || + inputs.model === 'veo31-ref' || + inputs.model === 'veo31-fast'; + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; const imageSizeOptions = [ { label: '1:1 方图 (1024x1024)', value: '1024x1024' }, { label: '3:2 横图 (1792x1024)', value: '1792x1024' }, @@ -129,9 +104,9 @@ const SettingsPanel = ({ { label: '1024x1792', value: '1024x1792' }, { label: '1024x1024', value: '1024x1024' }, ]; - const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((value) => ({ - label: `${value}s`, - value: String(value), + const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((v) => ({ + label: `${v}s`, + value: String(v), })); const videoPresetOptions = [ { label: 'Normal', value: 'normal' }, @@ -167,13 +142,13 @@ const SettingsPanel = ({ { label: '2K', value: '2K' }, ]; const adobe4KResolutionOptions = [{ label: '4K', value: '4K' }]; - const adobeSoraDurationOptions = [4, 8, 12].map((value) => ({ - label: `${value}s`, - value: String(value), + const adobeSoraDurationOptions = [4, 8, 12].map((v) => ({ + label: `${v}s`, + value: String(v), })); - const adobeVeoDurationOptions = [4, 6, 8].map((value) => ({ - label: `${value}s`, - value: String(value), + const adobeVeoDurationOptions = [4, 6, 8].map((v) => ({ + label: `${v}s`, + value: String(v), })); const adobeVideoResolutionOptions = [ { label: '1080p', value: '1080p' }, @@ -190,7 +165,6 @@ const SettingsPanel = ({ showDebugPanel, customRequestMode, customRequestBody, - playgroundMode, }; return ( @@ -204,6 +178,7 @@ const SettingsPanel = ({ flexDirection: 'column', }} > + {/* 标题区域 - 与调试面板保持一致 */}
@@ -226,6 +201,7 @@ const SettingsPanel = ({ )}
+ {/* 移动端配置管理 */} {styleState.isMobile && (
-
-
-
-
- -
-
- - {t(modeSummary.titleKey)} - - - {t(modeSummary.descriptionKey)} - - {!modeHasAvailableModels && ( - - {t( - '当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。', - )} - - )} -
-
-
-
- + {/* 自定义请求体编辑器 */} + {/* 分组选择 */}
@@ -307,6 +256,7 @@ const SettingsPanel = ({ />
+ {/* 模型选择 */}
@@ -337,6 +287,7 @@ const SettingsPanel = ({ />
+ {/* 图片URL输入 */}
+ {/* 参数控制组件 */}
- {isCurrentGrokImagineImageModel && ( + {/* 视频参数(仅视频模型显示) */} + {isGrokImagineImageModel && (
@@ -376,7 +329,7 @@ const SettingsPanel = ({
)} - {isCurrentAdobeImageModel && ( + {isAdobeImageModel && (
@@ -412,27 +365,26 @@ const SettingsPanel = ({
- {isCurrentAdobeVeoModel && ( + {isAdobeVeoModel && (
Resolution @@ -558,6 +510,7 @@ const SettingsPanel = ({
)} + {/* 流式输出开关 */}
@@ -583,6 +536,7 @@ const SettingsPanel = ({
+ {/* 桌面端的配置管理放在底部 */} {!styleState.isMobile && (
{ parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode, customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody, - playgroundMode: - parsedConfig.playgroundMode || DEFAULT_CONFIG.playgroundMode, }; return mergedConfig; diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index cd1d12a9c4e7..f11251d84dc1 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -30,12 +30,6 @@ export const MESSAGE_ROLES = { SYSTEM: 'system', }; -export const PLAYGROUND_MODES = { - CHAT: 'chat', - IMAGE: 'image', - VIDEO: 'video', -}; - // 默认消息示例 - 使用函数生成以支持 i18n export const getDefaultMessages = (t) => [ { @@ -127,7 +121,6 @@ export const DEFAULT_CONFIG = { showDebugPanel: false, customRequestMode: false, customRequestBody: '', - playgroundMode: PLAYGROUND_MODES.CHAT, }; // ========== 正则表达式 ========== diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 2173826a6c44..baa136c550ab 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -25,15 +25,6 @@ import { } from './utils'; import axios from 'axios'; import { MESSAGE_ROLES } from '../constants/playground.constants'; -import { - isAdobeImage4KModel, - isAdobeImageModel, - isAdobeVeoModel, - isAdobeVideoModel, - isGrokImagineImageModel, - isGrokImagineVideoModel, - isVideoModeModel, -} from './playgroundMode'; export let API = axios.create({ baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL @@ -133,6 +124,26 @@ export const buildApiPayload = ( } return size; }; + const grokImagineImageModels = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', + 'grok-imagine-1.0-edit', + ]); + const adobeImageModels = new Set([ + 'nano-banana', + 'nano-banana-4k', + 'nano-banana2', + 'nano-banana2-4k', + 'nano-banana-pro', + 'nano-banana-pro-4k', + ]); + const adobeVideoModels = new Set([ + 'sora2', + 'sora2-pro', + 'veo31', + 'veo31-ref', + 'veo31-fast', + ]); const processedMessages = messages .filter(isValidMessage) .map(formatMessageForAPI) @@ -173,23 +184,35 @@ export const buildApiPayload = ( } }); + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + const isGrokImagineImageModel = grokImagineImageModels.has(inputs.model); + const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; + const isAdobeImageModel = adobeImageModels.has(inputs.model); + const isAdobeVideoModel = adobeVideoModels.has(inputs.model); + const isAdobeImage4KModel = + typeof inputs.model === 'string' && inputs.model.endsWith('-4k'); + const isAdobeVeoModel = + inputs.model === 'veo31' || + inputs.model === 'veo31-ref' || + inputs.model === 'veo31-fast'; const adobeAspectRatioRaw = - inputs.aspectRatio || (isAdobeVideoModel(inputs.model) ? '16:9' : '1:1'); + inputs.aspectRatio || (isAdobeVideoModel ? '16:9' : '1:1'); const adobeAspectRatio = adobeAspectRatioRaw === 'auto' ? '' : adobeAspectRatioRaw; - if (isGrokImagineImageModel(inputs.model)) { + if (isGrokImagineImageModel) { payload.stream = false; if (inputs.imageSize) { payload.size = normalizeGrokImageSize(inputs.imageSize); } } - if (isAdobeImageModel(inputs.model)) { + if (isAdobeImageModel) { if (adobeAspectRatio) { payload.aspect_ratio = adobeAspectRatio; } else if (inputs.autoImageSize) { payload.size = inputs.autoImageSize; } - if (isAdobeImage4KModel(inputs.model)) { + if (isAdobeImage4KModel) { payload.output_resolution = '4K'; } else if (inputs.outputResolution) { payload.output_resolution = inputs.outputResolution; @@ -197,7 +220,7 @@ export const buildApiPayload = ( payload.output_resolution = '2K'; } } - if (isVideoModeModel(inputs.model)) { + if (isVideoModel) { payload.stream = false; if (inputs.videoSize) { payload.size = inputs.videoSize; @@ -218,17 +241,14 @@ export const buildApiPayload = ( : resolutionName === '480p' ? 'standard' : resolutionName; - if (isGrokImagineVideoModel(inputs.model) && resolutionName) { + if (isGrokImagineVideoModel && resolutionName) { payload.resolution_name = resolutionName; } } - if (isGrokImagineVideoModel(inputs.model) && inputs.videoPreset) { + if (isGrokImagineVideoModel && inputs.videoPreset) { payload.preset = inputs.videoPreset; } - if ( - isGrokImagineVideoModel(inputs.model) && - (payload.resolution_name || payload.preset) - ) { + if (isGrokImagineVideoModel && (payload.resolution_name || payload.preset)) { payload.video_config = { ...(payload.resolution_name ? { resolution_name: payload.resolution_name } @@ -237,10 +257,10 @@ export const buildApiPayload = ( }; } } - if (isAdobeVideoModel(inputs.model)) { + if (isAdobeVideoModel) { payload.duration = Number(inputs.videoDuration || 4); payload.aspect_ratio = adobeAspectRatio; - if (isAdobeVeoModel(inputs.model)) { + if (isAdobeVeoModel) { payload.resolution = inputs.videoResolution || '1080p'; } if (inputs.model === 'veo31-ref') { diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js index 3c337574d0a2..a86c3bca5996 100644 --- a/web/src/helpers/index.js +++ b/web/src/helpers/index.js @@ -30,4 +30,3 @@ export * from './boolean'; export * from './dashboard'; export * from './passkey'; export * from './statusCodeRules'; -export * from './playgroundMode'; diff --git a/web/src/helpers/playgroundMode.js b/web/src/helpers/playgroundMode.js deleted file mode 100644 index 821166b83b95..000000000000 --- a/web/src/helpers/playgroundMode.js +++ /dev/null @@ -1,108 +0,0 @@ -export const PLAYGROUND_MODES = { - CHAT: 'chat', - IMAGE: 'image', - VIDEO: 'video', -}; - -const GROK_IMAGE_GENERATION_MODELS = new Set([ - 'grok-imagine-1.0', - 'grok-imagine-1.0-fast', -]); - -const GROK_IMAGE_EDIT_MODELS = new Set(['grok-imagine-1.0-edit']); - -const ADOBE_IMAGE_MODELS = new Set([ - 'nano-banana', - 'nano-banana-4k', - 'nano-banana2', - 'nano-banana2-4k', - 'nano-banana-pro', - 'nano-banana-pro-4k', -]); - -const ADOBE_VIDEO_MODELS = new Set([ - 'sora2', - 'sora2-pro', - 'veo31', - 'veo31-ref', - 'veo31-fast', -]); - -export const isGrokImagineImageGenerationModel = (model) => - GROK_IMAGE_GENERATION_MODELS.has(model); - -export const isGrokImagineImageEditModel = (model) => - GROK_IMAGE_EDIT_MODELS.has(model); - -export const isGrokImagineImageModel = (model) => - isGrokImagineImageGenerationModel(model) || isGrokImagineImageEditModel(model); - -export const usesDedicatedImageGenerationEndpoint = (model) => - isGrokImagineImageModel(model); - -export const isAdobeImageModel = (model) => ADOBE_IMAGE_MODELS.has(model); - -export const isAdobeImage4KModel = (model) => - typeof model === 'string' && model.endsWith('-4k'); - -export const isImageModeModel = (model) => - isGrokImagineImageModel(model) || isAdobeImageModel(model); - -export const isGrokImagineVideoModel = (model) => - model === 'grok-imagine-1.0-video'; - -export const isAdobeVideoModel = (model) => ADOBE_VIDEO_MODELS.has(model); - -export const isAdobeSoraModel = (model) => - model === 'sora2' || model === 'sora2-pro'; - -export const isAdobeVeoModel = (model) => - model === 'veo31' || model === 'veo31-ref' || model === 'veo31-fast'; - -export const isVideoModeModel = (model) => - isGrokImagineVideoModel(model) || isAdobeVideoModel(model); - -export const isChatModeModel = (model) => - typeof model === 'string' && - model.trim() !== '' && - !isImageModeModel(model) && - !isVideoModeModel(model); - -export const isModelCompatibleWithPlaygroundMode = (model, mode) => { - switch (mode) { - case PLAYGROUND_MODES.IMAGE: - return isImageModeModel(model); - case PLAYGROUND_MODES.VIDEO: - return isVideoModeModel(model); - case PLAYGROUND_MODES.CHAT: - default: - return isChatModeModel(model); - } -}; - -export const getModelValues = (models = []) => - models - .map((item) => { - if (typeof item === 'string') { - return item; - } - return item?.value; - }) - .filter((item) => typeof item === 'string' && item.trim() !== ''); - -export const getAvailableModelsForPlaygroundMode = (models = [], mode) => - getModelValues(models).filter((model) => - isModelCompatibleWithPlaygroundMode(model, mode), - ); - -export const getPreferredModelForPlaygroundMode = ( - currentModel, - models = [], - mode, -) => { - if (isModelCompatibleWithPlaygroundMode(currentModel, mode)) { - return currentModel; - } - - return getAvailableModelsForPlaygroundMode(models, mode)[0] || ''; -}; diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index c269a2daf856..9f9ac036d6ea 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -28,13 +28,15 @@ import { import { getUserIdFromLocalStorage, handleApiError, - isGrokImagineImageEditModel, - isGrokImagineVideoModel, processThinkTags, processIncompleteThinkTags, - usesDedicatedImageGenerationEndpoint, - isVideoModeModel, } from '../../helpers'; + +const GROK_IMAGE_GENERATION_MODELS = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', +]); +const GROK_IMAGE_EDIT_MODELS = new Set(['grok-imagine-1.0-edit']); const normalizeGrokImageSize = (size) => { if (size === '1536x1024') { return '1792x1024'; @@ -54,15 +56,27 @@ export const useApiRequest = ( ) => { const { t } = useTranslation(); + const isGrokImagineImageModel = useCallback((model) => { + return ( + GROK_IMAGE_GENERATION_MODELS.has(model) || GROK_IMAGE_EDIT_MODELS.has(model) + ); + }, []); + + const isGrokImagineImageEditModel = useCallback((model) => { + return GROK_IMAGE_EDIT_MODELS.has(model); + }, []); + const isVideoGenerationPayload = useCallback((payload) => { - return isVideoModeModel(payload?.model); + const model = payload?.model; + return typeof model === 'string' && model.includes('video'); }, []); const isImageGenerationPayload = useCallback( (payload) => { - return usesDedicatedImageGenerationEndpoint(payload?.model); + const model = payload?.model; + return typeof model === 'string' && isGrokImagineImageModel(model); }, - [], + [isGrokImagineImageModel], ); const getTextFromMessageContent = useCallback((content) => { diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index fb137db23d60..79be10134adf 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -78,9 +78,6 @@ export const usePlaygroundState = () => { const [customRequestBody, setCustomRequestBody] = useState( savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody, ); - const [playgroundMode, setPlaygroundMode] = useState( - savedConfig.playgroundMode || DEFAULT_CONFIG.playgroundMode, - ); // UI状态 const [showSettings, setShowSettings] = useState(false); @@ -156,7 +153,6 @@ export const usePlaygroundState = () => { showDebugPanel, customRequestMode, customRequestBody, - playgroundMode, }; saveConfig(configToSave); }, 1000); @@ -166,7 +162,6 @@ export const usePlaygroundState = () => { showDebugPanel, customRequestMode, customRequestBody, - playgroundMode, ]); // 配置导入/重置 @@ -189,9 +184,6 @@ export const usePlaygroundState = () => { if (importedConfig.customRequestBody) { setCustomRequestBody(importedConfig.customRequestBody); } - if (importedConfig.playgroundMode) { - setPlaygroundMode(importedConfig.playgroundMode); - } // 如果导入的配置包含消息,也恢复消息 if (importedConfig.messages && Array.isArray(importedConfig.messages)) { setMessage(importedConfig.messages); @@ -206,7 +198,6 @@ export const usePlaygroundState = () => { setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel); setCustomRequestMode(DEFAULT_CONFIG.customRequestMode); setCustomRequestBody(DEFAULT_CONFIG.customRequestBody); - setPlaygroundMode(DEFAULT_CONFIG.playgroundMode); // 只有在明确指定时才重置消息 if (resetMessages) { @@ -293,7 +284,6 @@ export const usePlaygroundState = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, - setPlaygroundMode, setShowSettings, setModels, setGroups, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index bc1d6f7d4388..e392379e9455 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3348,38 +3348,5 @@ "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens", "例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$", "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching." - , - "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", - "当前模型": "Current model", - "未选择模型": "No model selected", - "智能对话": "Smart Chat", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", - "图片创作": "Image Creation", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", - "视频创作": "Video Creation", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", - "可用模型": "Available models", - "当前模式": "Current mode", - "切换到此模式": "Switch to this mode", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", - "智能对话工作区": "Smart Chat Workspace", - "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", - "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", - "图片创作工作区": "Image Creation Workspace", - "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", - "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", - "视频创作工作区": "Video Creation Workspace", - "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", - "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 15036268f0b2..6ff22ab56639 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3309,38 +3309,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens" - , - "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", - "当前模型": "Current model", - "未选择模型": "No model selected", - "智能对话": "Smart Chat", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", - "图片创作": "Image Creation", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", - "视频创作": "Video Creation", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", - "可用模型": "Available models", - "当前模式": "Current mode", - "切换到此模式": "Switch to this mode", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", - "智能对话工作区": "Smart Chat Workspace", - "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", - "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", - "图片创作工作区": "Image Creation Workspace", - "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", - "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", - "视频创作工作区": "Video Creation Workspace", - "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", - "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 954c5b50ca47..b2a59fc4e817 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3290,38 +3290,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens" - , - "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", - "当前模型": "Current model", - "未选择模型": "No model selected", - "智能对话": "Smart Chat", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", - "图片创作": "Image Creation", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", - "视频创作": "Video Creation", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", - "可用模型": "Available models", - "当前模式": "Current mode", - "切换到此模式": "Switch to this mode", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", - "智能对话工作区": "Smart Chat Workspace", - "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", - "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", - "图片创作工作区": "Image Creation Workspace", - "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", - "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", - "视频创作工作区": "Video Creation Workspace", - "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", - "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 9c8512560f11..ccf102425e13 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3323,38 +3323,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens" - , - "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", - "当前模型": "Current model", - "未选择模型": "No model selected", - "智能对话": "Smart Chat", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", - "图片创作": "Image Creation", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", - "视频创作": "Video Creation", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", - "可用模型": "Available models", - "当前模式": "Current mode", - "切换到此模式": "Switch to this mode", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", - "智能对话工作区": "Smart Chat Workspace", - "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", - "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", - "图片创作工作区": "Image Creation Workspace", - "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", - "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", - "视频创作工作区": "Video Creation Workspace", - "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", - "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 3d25ece00f48..5c9e1e8c0976 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3860,38 +3860,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens" - , - "创作中心": "Creation Center", - "在一个操练场里切换对话、图片和视频创作": "Switch between chat, image, and video creation inside one playground", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "The workspace below keeps reusing the current playground capabilities. Choose a creation mode here and the system will align the most suitable model and settings first.", - "当前模型": "Current model", - "未选择模型": "No model selected", - "智能对话": "Smart Chat", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "Best for long context, multi-turn follow-up, prompt tuning, and structured output.", - "图片创作": "Image Creation", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "Generate or edit images around prompts, aspect ratios, and reference images.", - "视频创作": "Video Creation", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "Focus on duration, quality, and reference mode to organize video generation quickly.", - "可用模型": "Available models", - "当前模式": "Current mode", - "切换到此模式": "Switch to this mode", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "Organize work around system prompts, multi-turn messages, and streaming responses.", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "Prioritize image size, ratio, and references so the same playground workspace feels tuned for image generation.", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "Focus on video duration, quality, and reference mode while reusing the existing playground video flow.", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "This account currently has no models suited for this creation mode. Switch modes or keep the full model list visible to inspect what is available.", - "智能对话工作区": "Smart Chat Workspace", - "输入你的问题、任务或提示词方向...": "Enter your question, task, or prompt direction...", - "当前账号暂无适合智能对话的模型": "No models for smart chat are currently available on this account", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "You can switch to image or video creation first, or inspect the full model list returned for this account in the left configuration panel.", - "图片创作工作区": "Image Creation Workspace", - "可继续使用文生图与带图编辑能力": "Text-to-image and image-edit flows remain available here", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "Describe the image you want, or enable image input first and then continue with image editing...", - "当前账号暂无适合图片创作的模型": "No models for image creation are currently available on this account", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "Image creation prefers image-capable models first. If this account has not returned any, switch modes or wait for the model configuration to update.", - "视频创作工作区": "Video Creation Workspace", - "可继续使用文生视频与参考图视频能力": "Text-to-video and reference-image video flows remain available here", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "Describe the shot, pacing, and style you want for the video. If needed, enable image input first as a reference...", - "当前账号暂无适合视频创作的模型": "No models for video creation are currently available on this account", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "Video creation prefers video-capable models first. If this account has not returned any yet, switch to another creation mode for now." } } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index f47286e36607..6b754ccde302 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2957,38 +2957,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" - , - "创作中心": "创作中心", - "在一个操练场里切换对话、图片和视频创作": "在一个操练场里切换对话、图片和视频创作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。", - "当前模型": "当前模型", - "未选择模型": "未选择模型", - "智能对话": "智能对话", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "适合长上下文、多轮追问、提示词调试和结构化输出。", - "图片创作": "图片创作", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "围绕提示词、尺寸比例和参考图来生成或编辑图像。", - "视频创作": "视频创作", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "聚焦时长、清晰度和参考模式,快速组织视频生成任务。", - "可用模型": "可用模型", - "当前模式": "当前模式", - "切换到此模式": "切换到此模式", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "围绕系统提示词、多轮消息和流式响应来组织创作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。", - "智能对话工作区": "智能对话工作区", - "输入你的问题、任务或提示词方向...": "输入你的问题、任务或提示词方向...", - "当前账号暂无适合智能对话的模型": "当前账号暂无适合智能对话的模型", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。", - "图片创作工作区": "图片创作工作区", - "可继续使用文生图与带图编辑能力": "可继续使用文生图与带图编辑能力", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "描述想要生成的画面,或先开启图片输入后再进行图片编辑...", - "当前账号暂无适合图片创作的模型": "当前账号暂无适合图片创作的模型", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。", - "视频创作工作区": "视频创作工作区", - "可继续使用文生视频与参考图视频能力": "可继续使用文生视频与参考图视频能力", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...", - "当前账号暂无适合视频创作的模型": "当前账号暂无适合视频创作的模型", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。" } } diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 8babb2ad0487..4e2f63564a24 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2974,38 +2974,5 @@ "输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens" - , - "创作中心": "創作中心", - "在一个操练场里切换对话、图片和视频创作": "在一個操練場裡切換對話、圖片和影片創作", - "下方工作区继续复用现有操练场能力,你只需要在这里选择创作模式,系统会优先为你对齐合适的模型与配置。": "下方工作區會繼續沿用現有操練場能力,你只需要在這裡選擇創作模式,系統會優先幫你對齊合適的模型與配置。", - "当前模型": "目前模型", - "未选择模型": "尚未選擇模型", - "智能对话": "智慧對話", - "适合长上下文、多轮追问、提示词调试和结构化输出。": "適合長上下文、多輪追問、提示詞調試與結構化輸出。", - "图片创作": "圖片創作", - "围绕提示词、尺寸比例和参考图来生成或编辑图像。": "圍繞提示詞、尺寸比例與參考圖來生成或編輯圖像。", - "视频创作": "影片創作", - "聚焦时长、清晰度和参考模式,快速组织视频生成任务。": "聚焦時長、清晰度與參考模式,快速組織影片生成任務。", - "可用模型": "可用模型", - "当前模式": "目前模式", - "切换到此模式": "切換到此模式", - "围绕系统提示词、多轮消息和流式响应来组织创作。": "圍繞系統提示詞、多輪訊息與串流回應來組織創作。", - "优先调整图片尺寸、比例和参考图,让同一套操练场工作区更适合图像生成。": "優先調整圖片尺寸、比例與參考圖,讓同一套操練場工作區更適合圖像生成。", - "聚焦视频时长、清晰度和参考模式,继续复用操练场现有视频生成链路。": "聚焦影片時長、清晰度與參考模式,繼續沿用操練場現有的影片生成鏈路。", - "当前账号下暂无适合此创作模式的模型,可切换模式或保留全量模型列表进行查看。": "目前帳號下暫無適合此創作模式的模型,可切換模式或保留完整模型列表查看。", - "智能对话工作区": "智慧對話工作區", - "输入你的问题、任务或提示词方向...": "輸入你的問題、任務或提示詞方向...", - "当前账号暂无适合智能对话的模型": "目前帳號下暫無適合智慧對話的模型", - "可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。": "可以先切換到圖片創作或影片創作,也可以在左側模型配置中查看目前帳號返回的完整模型列表。", - "图片创作工作区": "圖片創作工作區", - "可继续使用文生图与带图编辑能力": "可繼續使用文生圖與帶圖編輯能力", - "描述想要生成的画面,或先开启图片输入后再进行图片编辑...": "描述想要生成的畫面,或先開啟圖片輸入後再進行圖片編輯...", - "当前账号暂无适合图片创作的模型": "目前帳號下暫無適合圖片創作的模型", - "图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。": "圖片創作會優先匹配圖片模型;如果目前帳號沒有返回相關模型,請切換模式或等待模型配置更新。", - "视频创作工作区": "影片創作工作區", - "可继续使用文生视频与参考图视频能力": "可繼續使用文生影片與參考圖影片能力", - "描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...": "描述想生成的影片鏡頭、節奏和風格,必要時可先開啟圖片輸入作為參考圖...", - "当前账号暂无适合视频创作的模型": "目前帳號下暫無適合影片創作的模型", - "视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。": "影片創作會優先匹配影片模型;如果目前帳號尚未返回相關模型,可先切換到其它創作模式。" } } diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx index d8d9f4b9e002..68a97c335bde 100644 --- a/web/src/pages/Playground/index.jsx +++ b/web/src/pages/Playground/index.jsx @@ -17,45 +17,51 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useCallback, useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Card, Layout, Toast, Typography } from '@douyinfe/semi-ui'; -import { AlertTriangle } from 'lucide-react'; +import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; + +// Context import { UserContext } from '../../context/User'; import { useIsMobile } from '../../hooks/common/useIsMobile'; + +// hooks import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState'; import { useMessageActions } from '../../hooks/playground/useMessageActions'; import { useApiRequest } from '../../hooks/playground/useApiRequest'; import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody'; import { useMessageEdit } from '../../hooks/playground/useMessageEdit'; import { useDataLoader } from '../../hooks/playground/useDataLoader'; -import { ERROR_MESSAGES, MESSAGE_ROLES } from '../../constants/playground.constants'; + +// Constants and utils import { - buildApiPayload, + MESSAGE_ROLES, + ERROR_MESSAGES, +} from '../../constants/playground.constants'; +import { + getLogo, + stringToColor, buildMessageContent, - createLoadingAssistantMessage, createMessage, - encodeToBase64, - getAvailableModelsForPlaygroundMode, - getLogo, - getPreferredModelForPlaygroundMode, + createLoadingAssistantMessage, getTextContent, - isModelCompatibleWithPlaygroundMode, - PLAYGROUND_MODES, - stringToColor, + buildApiPayload, + encodeToBase64, } from '../../helpers'; + +// Components import { + OptimizedSettingsPanel, OptimizedDebugPanel, - OptimizedMessageActions, OptimizedMessageContent, - OptimizedSettingsPanel, + OptimizedMessageActions, } from '../../components/playground/OptimizedComponents'; import ChatArea from '../../components/playground/ChatArea'; import FloatingButtons from '../../components/playground/FloatingButtons'; -import PlaygroundCreationCenter from '../../components/playground/PlaygroundCreationCenter'; import { PlaygroundProvider } from '../../contexts/PlaygroundContext'; +// 生成头像 const generateAvatarDataUrl = (username) => { if (!username) { return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'; @@ -85,10 +91,10 @@ const Playground = () => { showDebugPanel, customRequestMode, customRequestBody, - playgroundMode, showSettings, models, groups, + status, message, debugData, activeDebugTab, @@ -104,6 +110,7 @@ const Playground = () => { setShowSettings, setModels, setGroups, + setStatus, setMessage, setDebugData, setActiveDebugTab, @@ -111,9 +118,9 @@ const Playground = () => { setShowDebugPanel, setCustomRequestMode, setCustomRequestBody, - setPlaygroundMode, } = state; + // API 请求相关 const { sendRequest, onStopGenerator } = useApiRequest( setMessage, setDebugData, @@ -122,8 +129,10 @@ const Playground = () => { saveMessagesImmediately, ); + // 数据加载 useDataLoader(userState, inputs, handleInputChange, setModels, setGroups); + // 消息编辑 const { editingMessageId, editValue, @@ -139,6 +148,7 @@ const Playground = () => { saveMessagesImmediately, ); + // 消息和自定义请求体同步 const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody( customRequestMode, @@ -150,6 +160,7 @@ const Playground = () => { debouncedSaveConfig, ); + // 角色信息 const roleInfo = { user: { name: userState?.user?.username || 'User', @@ -165,94 +176,51 @@ const Playground = () => { }, }; - const availableModeModels = { - [PLAYGROUND_MODES.CHAT]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.CHAT, - ), - [PLAYGROUND_MODES.IMAGE]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.IMAGE, - ), - [PLAYGROUND_MODES.VIDEO]: getAvailableModelsForPlaygroundMode( - models, - PLAYGROUND_MODES.VIDEO, - ), - }; - const modelsLoaded = models.length > 0; - const modeCounts = { - [PLAYGROUND_MODES.CHAT]: availableModeModels.chat.length, - [PLAYGROUND_MODES.IMAGE]: availableModeModels.image.length, - [PLAYGROUND_MODES.VIDEO]: availableModeModels.video.length, - }; - const modeHasAvailableModels = !modelsLoaded || modeCounts[playgroundMode] > 0; - - const modeUi = { - [PLAYGROUND_MODES.CHAT]: { - title: t('智能对话工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('选择模型开始创作'), - placeholder: t('输入你的问题、任务或提示词方向...'), - unavailableTitle: t('当前账号暂无适合智能对话的模型'), - unavailableDescription: t( - '可以先切换到图片创作或视频创作,也可以在左侧模型配置中查看当前账号返回的全量模型列表。', - ), - }, - [PLAYGROUND_MODES.IMAGE]: { - title: t('图片创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生图与带图编辑能力'), - placeholder: t('描述想要生成的画面,或先开启图片输入后再进行图片编辑...'), - unavailableTitle: t('当前账号暂无适合图片创作的模型'), - unavailableDescription: t( - '图片创作会优先匹配图片模型;如果当前账号没有返回相关模型,请切换模式或等待模型配置更新。', - ), - }, - [PLAYGROUND_MODES.VIDEO]: { - title: t('视频创作工作区'), - subtitle: inputs.model - ? `${t('当前模型')} · ${inputs.model}` - : t('可继续使用文生视频与参考图视频能力'), - placeholder: t('描述想生成的视频镜头、节奏和风格,必要时可先开启图片输入作为参考图...'), - unavailableTitle: t('当前账号暂无适合视频创作的模型'), - unavailableDescription: t( - '视频创作会优先匹配视频模型;如果当前账号暂未返回相关模型,可先切换到其它创作模式。', - ), - }, - }; - const activeModeUi = modeUi[playgroundMode] || modeUi[PLAYGROUND_MODES.CHAT]; + // 消息操作 + const messageActions = useMessageActions( + message, + setMessage, + onMessageSend, + saveMessagesImmediately, + ); + // 构建预览请求体 const constructPreviewPayload = useCallback(() => { try { + // 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体 if (customRequestMode && customRequestBody && customRequestBody.trim()) { try { return JSON.parse(customRequestBody); } catch (parseError) { - console.warn('Failed to parse custom request body for preview:', parseError); + console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError); } } - const messages = [...message]; + // 默认预览逻辑 + let messages = [...message]; + + // 如果存在用户消息 if ( !( messages.length === 0 || - messages.every((item) => item.role !== MESSAGE_ROLES.USER) + messages.every((msg) => msg.role !== MESSAGE_ROLES.USER) ) ) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index].role === MESSAGE_ROLES.USER) { + // 处理最后一个用户消息的图片 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === MESSAGE_ROLES.USER) { if (inputs.imageEnabled && inputs.imageUrls) { const validImageUrls = inputs.imageUrls.filter( (url) => url.trim() !== '', ); if (validImageUrls.length > 0) { - const textContent = getTextContent(messages[index]) || '示例消息'; - messages[index] = { - ...messages[index], - content: buildMessageContent(textContent, validImageUrls, true), - }; + const textContent = getTextContent(messages[i]) || '示例消息'; + const content = buildMessageContent( + textContent, + validImageUrls, + true, + ); + messages[i] = { ...messages[i], content }; } } break; @@ -262,192 +230,103 @@ const Playground = () => { return buildApiPayload(messages, null, inputs, parameterEnabled); } catch (error) { - console.error('Failed to construct preview payload:', error); + console.error('构造预览请求体失败:', error); return null; } - }, [customRequestBody, customRequestMode, inputs, message, parameterEnabled]); + }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]); - const handleModeChange = useCallback( - (nextMode) => { - setPlaygroundMode(nextMode); - if (nextMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } + // 发送消息 + function onMessageSend(content, attachment) { + console.log('attachment: ', attachment); - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - nextMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, - [ - handleInputChange, - inputs.imageEnabled, - inputs.model, - models, - setPlaygroundMode, - ], - ); + // 创建用户消息和加载消息 + const userMessage = createMessage(MESSAGE_ROLES.USER, content); + const loadingMessage = createLoadingAssistantMessage(); - useEffect(() => { - if (playgroundMode === PLAYGROUND_MODES.CHAT && inputs.imageEnabled) { - handleInputChange('imageEnabled', false); - } - }, [handleInputChange, inputs.imageEnabled, playgroundMode]); + // 如果是自定义请求体模式 + if (customRequestMode && customRequestBody) { + try { + const customPayload = JSON.parse(customRequestBody); - useEffect(() => { - if (!modelsLoaded) { - return; - } + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessage, loadingMessage]; - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - if (preferredModel && preferredModel !== inputs.model) { - handleInputChange('model', preferredModel); - } - }, [handleInputChange, inputs.model, models, modelsLoaded, playgroundMode]); - - const onMessageSend = useCallback( - (content, attachment) => { - console.log('attachment: ', attachment); - - if (!customRequestMode) { - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const resolvedModel = preferredModel || inputs.model; - - if (!modeHasAvailableModels || !resolvedModel) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } + // 发送自定义请求体 + sendRequest(customPayload, customPayload.stream !== false); - if (!isModelCompatibleWithPlaygroundMode(resolvedModel, playgroundMode)) { - Toast.warning(activeModeUi.unavailableTitle); - return; - } + // 发送消息后保存,传入新消息列表 + setTimeout(() => saveMessagesImmediately(newMessages), 0); - if (resolvedModel !== inputs.model) { - handleInputChange('model', resolvedModel); - } + return newMessages; + }); + return; + } catch (error) { + console.error('自定义请求体JSON解析失败:', error); + Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); + return; } + } - const userMessage = createMessage(MESSAGE_ROLES.USER, content); - const loadingMessage = createLoadingAssistantMessage(); + // 默认模式 + const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== ''); + const messageContent = buildMessageContent( + content, + validImageUrls, + inputs.imageEnabled, + ); + const userMessageWithImages = createMessage( + MESSAGE_ROLES.USER, + messageContent, + ); - if (customRequestMode && customRequestBody) { - try { - const customPayload = JSON.parse(customRequestBody); - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessage, loadingMessage]; - sendRequest(customPayload, customPayload.stream !== false); - setTimeout(() => saveMessagesImmediately(newMessages), 0); - return newMessages; - }); - return; - } catch (error) { - console.error('Failed to parse custom request body:', error); - Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR); - return; - } - } + setMessage((prevMessage) => { + const newMessages = [...prevMessage, userMessageWithImages]; - const validImageUrls = (inputs.imageUrls || []).filter( - (url) => url.trim() !== '', - ); - const messageContent = buildMessageContent( - content, - validImageUrls, - inputs.imageEnabled, - ); - const userMessageWithImages = createMessage( - MESSAGE_ROLES.USER, - messageContent, + const payload = buildApiPayload( + newMessages, + null, + inputs, + parameterEnabled, ); + sendRequest(payload, inputs.stream); - const preferredModel = getPreferredModelForPlaygroundMode( - inputs.model, - models, - playgroundMode, - ); - const requestInputs = - preferredModel && preferredModel !== inputs.model - ? { ...inputs, model: preferredModel } - : inputs; - - setMessage((prevMessage) => { - const newMessages = [...prevMessage, userMessageWithImages]; - const payload = buildApiPayload( - newMessages, - null, - requestInputs, - parameterEnabled, - ); - sendRequest(payload, requestInputs.stream); - - if (inputs.imageEnabled) { - setTimeout(() => { - handleInputChange('imageEnabled', false); - }, 100); - } + // 禁用图片模式 + if (inputs.imageEnabled) { + setTimeout(() => { + handleInputChange('imageEnabled', false); + }, 100); + } - const messagesWithLoading = [...newMessages, loadingMessage]; - setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); - return messagesWithLoading; - }); - }, - [ - activeModeUi.unavailableTitle, - customRequestBody, - customRequestMode, - handleInputChange, - inputs, - modeHasAvailableModels, - models, - parameterEnabled, - playgroundMode, - saveMessagesImmediately, - sendRequest, - setMessage, - ], - ); + // 发送消息后保存,传入新消息列表(包含用户消息和加载消息) + const messagesWithLoading = [...newMessages, loadingMessage]; + setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0); - const messageActions = useMessageActions( - message, - setMessage, - onMessageSend, - saveMessagesImmediately, - ); + return messagesWithLoading; + }); + } + // 切换推理展开状态 const toggleReasoningExpansion = useCallback( (messageId) => { setMessage((prevMessages) => - prevMessages.map((item) => - item.id === messageId && item.role === MESSAGE_ROLES.ASSISTANT - ? { ...item, isReasoningExpanded: !item.isReasoningExpanded } - : item, + prevMessages.map((msg) => + msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT + ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded } + : msg, ), ); }, [setMessage], ); + // 渲染函数 const renderCustomChatContent = useCallback( - ({ message: currentMessage, className }) => { - const isCurrentlyEditing = editingMessageId === currentMessage.id; + ({ message, className }) => { + const isCurrentlyEditing = editingMessageId === message.id; return ( { ); }, [ - editValue, + styleState, editingMessageId, - handleEditCancel, + editValue, handleEditSave, + handleEditCancel, setEditValue, - styleState, toggleReasoningExpansion, ], ); @@ -474,7 +353,7 @@ const Playground = () => { (props) => { const { message: currentMessage } = props; const isAnyMessageGenerating = message.some( - (item) => item.status === 'loading' || item.status === 'incomplete', + (msg) => msg.status === 'loading' || msg.status === 'incomplete', ); const isCurrentlyEditing = editingMessageId === currentMessage.id; @@ -492,9 +371,12 @@ const Playground = () => { /> ); }, - [editingMessageId, handleMessageEdit, message, messageActions, styleState], + [messageActions, styleState, message, editingMessageId, handleMessageEdit], ); + // Effects + + // 同步消息和自定义请求体 useEffect(() => { syncMessageToCustomBody(); }, [message, syncMessageToCustomBody]); @@ -503,12 +385,16 @@ const Playground = () => { syncCustomBodyToMessage(); }, [customRequestBody, syncCustomBodyToMessage]); + // 处理URL参数 useEffect(() => { if (searchParams.get('expired')) { Toast.warning(t('登录过期,请重新登录!')); } }, [searchParams, t]); + // Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新 + + // 构建预览payload useEffect(() => { const timer = setTimeout(() => { const preview = constructPreviewPayload(); @@ -522,43 +408,49 @@ const Playground = () => { return () => clearTimeout(timer); }, [ - constructPreviewPayload, - customRequestBody, - customRequestMode, - inputs, message, + inputs, parameterEnabled, - setDebugData, + customRequestMode, + customRequestBody, + constructPreviewPayload, setPreviewPayload, + setDebugData, ]); + // 自动保存配置 useEffect(() => { debouncedSaveConfig(); }, [ - customRequestBody, - customRequestMode, - debouncedSaveConfig, inputs, parameterEnabled, - playgroundMode, showDebugPanel, + customRequestMode, + customRequestBody, + debouncedSaveConfig, ]); + // 清空对话的处理函数 const handleClearMessages = useCallback(() => { setMessage([]); + // 清空对话后保存,传入空数组 setTimeout(() => saveMessagesImmediately([]), 0); - }, [saveMessagesImmediately, setMessage]); + }, [setMessage, saveMessagesImmediately]); + // 处理粘贴图片 const handlePasteImage = useCallback( (base64Data) => { if (!inputs.imageEnabled) { return; } - handleInputChange('imageUrls', [...(inputs.imageUrls || []), base64Data]); + // 添加图片到 imageUrls 数组 + const newUrls = [...(inputs.imageUrls || []), base64Data]; + handleInputChange('imageUrls', newUrls); }, - [handleInputChange, inputs.imageEnabled, inputs.imageUrls], + [inputs.imageEnabled, inputs.imageUrls, handleInputChange], ); + // Playground Context 值 const playgroundContextValue = { onPasteImage: handlePasteImage, imageUrls: inputs.imageUrls || [], @@ -572,13 +464,13 @@ const Playground = () => { {(showSettings || !isMobile) && ( { showDebugPanel={showDebugPanel} customRequestMode={customRequestMode} customRequestBody={customRequestBody} - playgroundMode={playgroundMode} - modeHasAvailableModels={modeHasAvailableModels} onInputChange={handleInputChange} onParameterToggle={handleParameterToggle} onCloseSettings={() => setShowSettings(false)} @@ -607,79 +497,42 @@ const Playground = () => { )} -
-
- +
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} />
- {!modeHasAvailableModels && modelsLoaded && !customRequestMode && ( -
- -
-
- -
-
- - {activeModeUi.unavailableTitle} - - - {activeModeUi.unavailableDescription} - -
-
-
+ {/* 调试面板 - 桌面端 */} + {showDebugPanel && !isMobile && ( +
+
)} - -
-
-
- setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} - title={activeModeUi.title} - subtitle={activeModeUi.subtitle} - placeholder={activeModeUi.placeholder} - /> -
- - {showDebugPanel && !isMobile && ( -
- -
- )} -
-
+ {/* 调试面板 - 移动端覆盖层 */} {showDebugPanel && isMobile && (
{
)} + {/* 浮动按钮 */} Date: Mon, 30 Mar 2026 09:52:33 +0800 Subject: [PATCH 034/282] fix: show duration billing type in model management --- web/src/components/table/models/ModelsColumnDefs.jsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/components/table/models/ModelsColumnDefs.jsx b/web/src/components/table/models/ModelsColumnDefs.jsx index 4520a1b4df13..3086d0ca74e9 100644 --- a/web/src/components/table/models/ModelsColumnDefs.jsx +++ b/web/src/components/table/models/ModelsColumnDefs.jsx @@ -137,14 +137,22 @@ const renderQuotaTypes = (arr, t) => { return renderLimitedItems({ items: arr, renderItem: (qt, idx) => { - if (qt === 1) { + const quotaType = Number(qt); + if (quotaType === 1) { return ( {t('按次计费')} ); } - if (qt === 0) { + if (quotaType === 2) { + return ( + + {t('按时长计费')} + + ); + } + if (quotaType === 0) { return ( {t('按量计费')} From ec7062d84218076f851e4a173bb4b1093ef248ff Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 10:13:38 +0800 Subject: [PATCH 035/282] feat: add creation center skeleton page --- web/src/App.jsx | 9 + web/src/components/layout/SiderBar.jsx | 12 + web/src/helpers/render.jsx | 3 + web/src/hooks/common/useSidebar.js | 1 + web/src/pages/CreationCenter/index.jsx | 321 +++++++++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 web/src/pages/CreationCenter/index.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc00b32..90febf7088a9 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -43,6 +43,7 @@ import Pricing from './pages/Pricing'; import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; +import CreationCenter from './pages/CreationCenter'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; @@ -147,6 +148,14 @@ function App() { } /> + + + + } + /> {} }) => { const chatMenuItems = useMemo(() => { const items = [ + { + text: t('创作中心'), + itemKey: 'creation_hidden', + to: '/creation', + className: 'tableHiddle', + }, { text: t('操练场'), itemKey: 'playground', to: '/playground', }, + { + text: t('创作中心'), + itemKey: 'creation', + to: '/creation', + }, { text: t('聊天'), itemKey: 'chat', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f472e..2896d3b1b7a0 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -60,6 +60,7 @@ import { import { LayoutDashboard, + Sparkles, TerminalSquare, MessageSquare, Key, @@ -118,6 +119,8 @@ export function getLucideIcon(key, selected = false) { switch (key) { case 'detail': return ; + case 'creation': + return ; case 'playground': return ; case 'chat': diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index cd74ada20280..68a3f9c9709c 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -28,6 +28,7 @@ const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; export const DEFAULT_ADMIN_CONFIG = { chat: { enabled: true, + creation: true, playground: true, chat: true, }, diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx new file mode 100644 index 000000000000..9735b40ed660 --- /dev/null +++ b/web/src/pages/CreationCenter/index.jsx @@ -0,0 +1,321 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Button, Card, Tag, Typography } from '@douyinfe/semi-ui'; +import { + Clapperboard, + ImagePlus, + LayoutPanelLeft, + MessageSquareText, + Sparkles, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +const CREATION_SECTIONS = [ + { + key: 'chat', + titleKey: '智能对话', + descriptionKey: '大模型对话工作区骨架', + icon: MessageSquareText, + }, + { + key: 'image', + titleKey: '图片创作', + descriptionKey: '图片生成工作区骨架', + icon: ImagePlus, + }, + { + key: 'video', + titleKey: '视频创作', + descriptionKey: '视频生成工作区骨架', + icon: Clapperboard, + }, +]; + +const CHAT_BLOCKS = [ + '顶部标题栏', + '模型 / 模式占位条', + '聊天内容区', + '底部输入区', +]; + +const CreationWorkspace = ({ activeSection, t }) => { + if (activeSection === 'chat') { + return ( +
+ +
+
+ + {t('对话主工作区')} + + + {t('用于承载模型切换、会话内容和输入操作。')} + +
+ {t('低保真骨架')} +
+
+ + +
+ {CHAT_BLOCKS.map((block) => ( +
+ {t(block)} +
+ ))} +
+
+ + +
+
+
+ + + {t('会话内容占位')} + +
+
+
+ + {t('输入区占位')} + +
+
+
+
+ ); + } + + const titleKey = activeSection === 'image' ? '图片创作工作区' : '视频创作工作区'; + const descriptionKey = + activeSection === 'image' + ? '左侧用于放置生成配置,右侧用于放置结果预览。' + : '左侧用于放置视频配置,右侧用于放置生成结果与状态。'; + + return ( +
+ +
+
+ + {t(titleKey)} + + + {t(descriptionKey)} + +
+ + {t('低保真骨架')} + +
+
+ +
+ {[ + { key: 'config', titleKey: '配置区', sideKey: '左栏' }, + { key: 'result', titleKey: '结果区', sideKey: '右栏' }, + ].map((block, index) => ( + +
+
+ + {t(block.titleKey)} + + + {t(block.sideKey)} + +
+ +
+ {t(index === 0 ? '顶部标题栏' : '结果标题栏')} +
+ +
+ {t(index === 0 ? '主要内容占位区' : '结果展示占位区')} +
+ +
+
+ {t(index === 0 ? '参数卡片占位' : '状态卡片占位')} +
+
+ {t(index === 0 ? '附加操作占位' : '下载 / 操作占位')} +
+
+
+
+ ))} +
+
+ ); +}; + +const CreationCenter = () => { + const { t } = useTranslation(); + const [activeSection, setActiveSection] = useState('chat'); + + const currentSection = + CREATION_SECTIONS.find((section) => section.key === activeSection) || + CREATION_SECTIONS[0]; + + return ( +
+
+ +
+
+
+
+
+ + {t('创作工作台')} + + + {t('创作中心')} + + + {t( + '面向创作任务的独立工作区,先完成页面骨架与布局分区,后续再接入真实功能。', + )} + +
+ +
+
+ {t('当前为低保真占位页面,用于确认信息架构和版块布局。')} +
+
{t('工作区')}
+
+
+
+
+ + +
+ +
+ + {t('切换板块')} +
+ + {t('选择对应板块后,在右侧查看骨架布局。')} + + +
+ {CREATION_SECTIONS.map((section) => { + const Icon = section.icon; + const isActive = activeSection === section.key; + + return ( + + ); + })} +
+
+ +
+
+
+ + {t(currentSection.titleKey)} + + + {t(currentSection.descriptionKey)} + +
+ + {t('工作区')} + +
+ + +
+
+
+
+ ); +}; + +export default CreationCenter; From e53bee4120cf7dea35da756a090ff423bd21f365 Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 10:30:53 +0800 Subject: [PATCH 036/282] feat: polish creation center workspace ui --- web/src/pages/CreationCenter/index.jsx | 698 ++++++++++++++++++------- 1 file changed, 507 insertions(+), 191 deletions(-) diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx index 9735b40ed660..3084eeb4ff26 100644 --- a/web/src/pages/CreationCenter/index.jsx +++ b/web/src/pages/CreationCenter/index.jsx @@ -18,299 +18,615 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useState } from 'react'; -import { Button, Card, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Button, Card, Tag, Typography } from '@douyinfe/semi-ui'; import { Clapperboard, + Eye, + Image as ImageIcon, ImagePlus, LayoutPanelLeft, MessageSquareText, + Plus, + Send, + Settings2, + SlidersHorizontal, Sparkles, + Upload, + Wand2, } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -const CREATION_SECTIONS = [ +const TEXT = { + creationCenter: '\u521b\u4f5c\u4e2d\u5fc3', + unifiedStudio: '\u7edf\u4e00\u521b\u4f5c\u5de5\u4f5c\u53f0', + headerDesc: + '\u4e09\u79cd\u521b\u4f5c\u6a21\u5f0f\u5171\u7528\u540c\u4e00\u5957\u89c6\u89c9\u9aa8\u67b6\uff1a\u66f4\u8f7b\u76c8\u7684\u5361\u7247\u5c42\u7ea7\uff0c\u66f4\u7edf\u4e00\u7684\u5de5\u5177\u6761\uff0c\u66f4\u660e\u786e\u7684\u4e3b\u6b21\u5206\u533a\u3002', + currentWorkspace: '\u5f53\u524d\u5de5\u4f5c\u533a', + switchSection: '\u5207\u6362\u677f\u5757', + switchHint: + '\u53c2\u8003\u4e0d\u540c\u521b\u4f5c\u9875\u9762\u7684\u5e03\u5c40\u7279\u5f81\uff0c\u7edf\u4e00\u6210\u540c\u4e00\u5957\u521b\u4f5c\u4e2d\u5fc3\u98ce\u683c\u3002', + chat: '\u667a\u80fd\u5bf9\u8bdd', + image: '\u56fe\u7247\u521b\u4f5c', + video: '\u89c6\u9891\u521b\u4f5c', + chatSub: + '\u50cf\u53c2\u8003\u56fe\u4e00\u90a3\u6837\u4fdd\u7559\u5927\u9762\u79ef\u4f1a\u8bdd\u7a7a\u95f4\uff0c\u4f46\u7edf\u4e00\u5230\u521b\u4f5c\u4e2d\u5fc3\u7684\u5de5\u4f5c\u53f0\u8bed\u8a00\u91cc\u3002', + imageSub: + '\u53c2\u8003\u56fe\u4e8c\u7684\u53cc\u680f\u5e03\u5c40\uff0c\u5de6\u4fa7\u53c2\u6570\u8bbe\u7f6e\uff0c\u53f3\u4fa7\u751f\u6210\u7ed3\u679c\uff0c\u6574\u4f53\u89c6\u89c9\u66f4\u514b\u5236\u7edf\u4e00\u3002', + videoSub: + '\u53c2\u8003\u56fe\u4e09\u7684\u53cc\u680f\u5e03\u5c40\uff0c\u4fdd\u7559\u89c6\u9891\u7ed3\u679c\u548c\u72b6\u6001\u533a\uff0c\u540c\u65f6\u7edf\u4e00\u5361\u7247\u548c\u5c42\u6b21\u3002', + newChat: '\u65b0\u5bf9\u8bdd', + defaultMode: '\u9ed8\u8ba4\u6a21\u5f0f', + assistant: 'Assistant', + hello: '\u4f60\u597d', + assistantReply: '\u4f60\u597d\uff0c\u8bf7\u95ee\u6709\u4ec0\u4e48\u53ef\u4ee5\u5e2e\u52a9\u60a8\u7684\u5417\uff1f', + inputPlaceholder: + '\u8f93\u5165\u60a8\u7684\u6d88\u606f...(Enter\u53d1\u9001\uff0cShift+Enter \u6362\u884c)', + inputHint: + '\u6309 Enter \u53d1\u9001\uff0cShift+Enter \u6362\u884c\uff0c\u652f\u6301\u62d6\u62fd\u4e0a\u4f20\u56fe\u7247\u6216 Ctrl+V \u7c98\u8d34\u56fe\u7247', + imageSettings: '\u751f\u6210\u8bbe\u7f6e', + imageSettingsDesc: + '\u53c2\u8003\u56fe\u4e8c\u7684\u64cd\u4f5c\u6d41\u7a0b\uff0c\u7edf\u4e00\u4e3a\u66f4\u8f7b\u76c8\u7684\u521b\u4f5c\u5de5\u4f5c\u53f0\u6837\u5f0f\u3002', + videoSettings: '\u89c6\u9891\u751f\u6210', + videoSettingsDesc: + '\u6cbf\u7528\u53c2\u8003\u56fe\u4e09\u7684\u7ed3\u6784\uff0c\u628a\u53c2\u6570\u533a\uff0c\u63d0\u793a\u533a\u548c\u52a8\u4f5c\u6309\u94ae\u505a\u6210\u7edf\u4e00\u5361\u7247\u7cfb\u7edf\u3002', + uploadRef: '\u4e0a\u4f20\u53c2\u8003\u56fe\uff08\u53ef\u9009\uff09', + selectImage: '\u9009\u62e9\u56fe\u7247', + clear: '\u6e05\u7a7a', + uploadHint: + '\u5982\u679c\u4f20\u9012\u4e86\u56fe\u751f\u56fe\u6a21\u578b\uff0c\u53c2\u8003\u56fe\u624d\u4f1a\u751f\u6548\uff1b\u5426\u5219\u53ea\u4f20 prompt \u4e5f\u53ef\u4ee5\u751f\u6210\u3002', + model: '\u6a21\u578b', + prompt: 'Prompt', + promptPlaceholder: '\u63cf\u8ff0\u4f60\u60f3\u751f\u6210\u7684\u5185\u5bb9...', + count: '\u751f\u6210\u6570\u91cf', + note: '\u8bf4\u660e', + oneResult: '\u5c06\u4ea7\u751f 1 \u4e2a\u7ed3\u679c', + imageCountHint: + '\u6700\u591a\u540c\u65f6\u751f\u6210 4 \u5f20\uff0c\u5efa\u8bae 1-2 \u5f20\u3002', + createImageTask: '\u521b\u5efa\u751f\u6210\u4efb\u52a1', + result: '\u751f\u6210\u7ed3\u679c', + startCreating: '\u7b80\u5355\u4e09\u6b65\u5f00\u59cb\u521b\u4f5c', + imageResultNote: + '\u7ed3\u679c\u753b\u5e03\u4fdd\u6301\u6e05\u723d\u7559\u767d\uff0c\u540e\u7eed\u53ef\u4ee5\u65e0\u7f1d\u63a5\u5165\u771f\u5b9e\u751f\u6210\u9884\u89c8\u3002', + copyLink: '\u590d\u5236\u94fe\u63a5', + downloadImage: '\u4e0b\u8f7d\u56fe\u7247', + videoResult: '\u89c6\u9891\u7ed3\u679c', + downloadVideo: '\u4e0b\u8f7d\u89c6\u9891', + videoResultNote: + '\u6587\u751f\u89c6\u9891\uff08T2V\uff09\uff1a\u586b\u5199 Prompt \u5373\u53ef\uff0c\u5206\u8fa8\u7387\u5df2\u6309\u6a21\u578b\u63a8\u8350\uff0c\u65e0\u9700\u4e0a\u4f20\u56fe\u7247\u3002', + uploadImage: '\u4e0a\u4f20\u56fe\u7247', + enterPrompt: '\u8f93\u5165\u63d0\u793a\u8bcd', + generate: '\u751f\u6210', + videoPromptPlaceholder: '\u63cf\u8ff0\u4f60\u8981\u751f\u6210\u7684\u89c6\u9891\u5185\u5bb9...', + sizeLabel: '\u5206\u8fa8\u7387 size', + duration: '\u65f6\u957f', + currentT2V: + '\u5f53\u524d\u4e3a\u6587\u751f\u89c6\u9891\uff08T2V\uff09\uff1a\u53ea\u9700\u586b\u5199 Prompt\uff0c\u65e0\u9700\u4e0a\u4f20\u56fe\u7247\u3002', + videoHint: + '\u652f\u6301\u8fde\u7eed\u63d0\u4ea4\u591a\u4e2a\u89c6\u9891\u4efb\u52a1\uff1b\u53f3\u4fa7\u7ed3\u679c\u533a\u4f18\u5148\u5c55\u793a\u6700\u65b0\u5b8c\u6210\u7684\u9884\u89c8\uff0c\u82e5\u6709\u66f4\u65b0\u4efb\u52a1\u4ecd\u5728\u751f\u6210\u4f1a\u663e\u793a\u8fdb\u5ea6\u63d0\u793a\u3002', + createVideoTask: '\u521b\u5efa\u89c6\u9891\u4efb\u52a1', + status: '\u72b6\u6001', + statusHint: + '\u9009\u62e9\u6a21\u578b\u540e\u5c06\u663e\u793a\u8bf4\u660e\uff1b\u63d0\u4ea4\u4efb\u52a1\u540e\u6b64\u5904\u663e\u793a\u8fdb\u5ea6\u63d0\u793a\u3002', + chatWorkspace: '\u5bf9\u8bdd\u5de5\u4f5c\u533a', + imageWorkspace: '\u56fe\u7247\u5de5\u4f5c\u533a', + videoWorkspace: '\u89c6\u9891\u5de5\u4f5c\u533a', + currentAreaTag: '\u5f53\u524d\u4e3a\u4f4e\u4fdd\u771f\u4f46\u9ad8\u8d28\u611f\u7684\u7ed3\u6784\u7a3f\uff0c\u7528\u4e8e\u7ee7\u7eed\u63a5\u5165\u771f\u5b9e\u529f\u80fd\u3002', +}; + +const SECTIONS = [ { key: 'chat', - titleKey: '智能对话', - descriptionKey: '大模型对话工作区骨架', + title: TEXT.chat, + subtitle: TEXT.chatSub, icon: MessageSquareText, + accent: 'from-sky-500 via-cyan-500 to-blue-600', + softAccent: 'from-sky-50 via-cyan-50 to-blue-50', + tagColor: 'blue', }, { key: 'image', - titleKey: '图片创作', - descriptionKey: '图片生成工作区骨架', + title: TEXT.image, + subtitle: TEXT.imageSub, icon: ImagePlus, + accent: 'from-fuchsia-500 via-violet-500 to-indigo-600', + softAccent: 'from-fuchsia-50 via-violet-50 to-indigo-50', + tagColor: 'violet', }, { key: 'video', - titleKey: '视频创作', - descriptionKey: '视频生成工作区骨架', + title: TEXT.video, + subtitle: TEXT.videoSub, icon: Clapperboard, + accent: 'from-cyan-500 via-sky-500 to-indigo-600', + softAccent: 'from-cyan-50 via-sky-50 to-indigo-50', + tagColor: 'cyan', }, ]; -const CHAT_BLOCKS = [ - '顶部标题栏', - '模型 / 模式占位条', - '聊天内容区', - '底部输入区', -]; +const panelClassName = + 'rounded-[30px] border border-slate-200/80 bg-white/92 shadow-[0_18px_48px_rgba(15,23,42,0.08)] backdrop-blur'; -const CreationWorkspace = ({ activeSection, t }) => { - if (activeSection === 'chat') { - return ( -
- -
-
- - {t('对话主工作区')} - - - {t('用于承载模型切换、会话内容和输入操作。')} - +const subtleCardClassName = + 'rounded-[26px] border border-slate-200/80 bg-white/88 shadow-[0_12px_30px_rgba(15,23,42,0.05)]'; + +const toolButtonClassName = + '!rounded-2xl !border-slate-200 !bg-white/90 !text-slate-600 hover:!bg-slate-50 hover:!text-slate-900'; + +const stepItems = [TEXT.uploadImage, TEXT.enterPrompt, TEXT.generate]; + +const SurfaceLabel = ({ children }) => ( + + {children} + +); + +const MockField = ({ label, value, hint, tall = false, action }) => ( +
+ {label} +
+
+ {value} + {action ? ( + + {action} + + ) : null} +
+ {hint ? ( + + {hint} + + ) : null} +
+
+); + +const StatusSteps = () => ( +
+ {stepItems.map((item, index) => ( +
+
+ {index + 1} +
+ {item} +
+ ))} +
+); + +const ResultEmpty = ({ icon, title, description, note, actions }) => ( +
+
+ + {title} + +
+ {actions?.map((action) => ( + + ))} +
+
+ + {note ? ( +
+ {note} +
+ ) : null} + +
+
+ {icon} +
+ + {title} + + + {description} + + +
+
+); + +const ChatWorkspace = () => ( +
+ +
+
+
+ +
+ gpt-4o +
+
+ {TEXT.defaultMode}
- {t('低保真骨架')}
- +
+
+
+
- -
- {CHAT_BLOCKS.map((block) => ( -
- {t(block)} +
+
+
+
+
+
+
+ google_QaXdNZGh + 2024-05-14 4:52pm + + G + +
+
+ {TEXT.hello} +
+
+ + + +
+
+
+ +
+
+ + A + + {TEXT.assistant} + 2024-05-14 4:52pm +
+
+ {TEXT.assistantReply} +
+
+ + + +
- ))} +
- - -
-
-
- - - {t('会话内容占位')} - +
+
+
+ default +
+
+ 1x +
+
+ gpt-4o
-
- - {t('输入区占位')} - +
+
+ {TEXT.inputPlaceholder} +
+
+ + {TEXT.inputHint} +
- +
- ); - } + +
+); - const titleKey = activeSection === 'image' ? '图片创作工作区' : '视频创作工作区'; - const descriptionKey = - activeSection === 'image' - ? '左侧用于放置生成配置,右侧用于放置结果预览。' - : '左侧用于放置视频配置,右侧用于放置生成结果与状态。'; +const ImageWorkspace = () => ( +
+ +
+
+ + {TEXT.imageSettings} + + + {TEXT.imageSettingsDesc} + +
+ {TEXT.image} +
- return ( -
- -
+
+ + + +
+ + +
+
+
+ +
+
+
+ + + + } + /> + +
+); + +const VideoWorkspace = () => ( +
+
+ +
- - {t(titleKey)} + + {TEXT.videoSettings} - {t(descriptionKey)} + {TEXT.videoSettingsDesc}
- - {t('低保真骨架')} - + {TEXT.video}
-
- -
- {[ - { key: 'config', titleKey: '配置区', sideKey: '左栏' }, - { key: 'result', titleKey: '结果区', sideKey: '右栏' }, - ].map((block, index) => ( - -
-
- - {t(block.titleKey)} - - - {t(block.sideKey)} - -
-
- {t(index === 0 ? '顶部标题栏' : '结果标题栏')} -
+
+ + +
+ + +
+
+ + {TEXT.currentT2V} + + + {TEXT.videoHint} + +
+
+
+ +
+
+
+ +
-
- {t(index === 0 ? '主要内容占位区' : '结果展示占位区')} -
+
+ + } + /> + -
-
- {t(index === 0 ? '参数卡片占位' : '状态卡片占位')} -
-
- {t(index === 0 ? '附加操作占位' : '下载 / 操作占位')} -
-
-
-
- ))} -
+ + {TEXT.status} + + {TEXT.statusHint} + +
- ); -}; +
+); const CreationCenter = () => { - const { t } = useTranslation(); const [activeSection, setActiveSection] = useState('chat'); const currentSection = - CREATION_SECTIONS.find((section) => section.key === activeSection) || - CREATION_SECTIONS[0]; + SECTIONS.find((section) => section.key === activeSection) || SECTIONS[0]; + + const CurrentIcon = currentSection.icon; return ( -
-
+
+
-
-
-
+
+
+
- {t('创作工作台')} + {TEXT.creationCenter} - {t('创作中心')} + {TEXT.unifiedStudio} - - {t( - '面向创作任务的独立工作区,先完成页面骨架与布局分区,后续再接入真实功能。', - )} + + {TEXT.headerDesc}
-
-
- {t('当前为低保真占位页面,用于确认信息架构和版块布局。')} +
+
+
+
+ +
+
+ + {currentSection.title} + + + {TEXT.currentWorkspace} + +
+
-
{t('工作区')}
-
+
-
+
- {t('切换板块')} + {TEXT.switchSection}
- - {t('选择对应板块后,在右侧查看骨架布局。')} + + {TEXT.switchHint}
- {CREATION_SECTIONS.map((section) => { + {SECTIONS.map((section) => { const Icon = section.icon; const isActive = activeSection === section.key; - return ( - + ); })}
-
-
- - {t(currentSection.titleKey)} - - - {t(currentSection.descriptionKey)} - -
- - {t('工作区')} - -
- - + {activeSection === 'chat' ? : null} + {activeSection === 'image' ? : null} + {activeSection === 'video' ? : null}
From 9bfa532630f51e08da1f8ebc3342ed4d41877620 Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 10:52:16 +0800 Subject: [PATCH 037/282] refactor: tighten creation center layout --- web/src/pages/CreationCenter/index.jsx | 109 ++++++++++--------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx index 3084eeb4ff26..f6671d05add7 100644 --- a/web/src/pages/CreationCenter/index.jsx +++ b/web/src/pages/CreationCenter/index.jsx @@ -38,9 +38,9 @@ import { const TEXT = { creationCenter: '\u521b\u4f5c\u4e2d\u5fc3', unifiedStudio: '\u7edf\u4e00\u521b\u4f5c\u5de5\u4f5c\u53f0', - headerDesc: - '\u4e09\u79cd\u521b\u4f5c\u6a21\u5f0f\u5171\u7528\u540c\u4e00\u5957\u89c6\u89c9\u9aa8\u67b6\uff1a\u66f4\u8f7b\u76c8\u7684\u5361\u7247\u5c42\u7ea7\uff0c\u66f4\u7edf\u4e00\u7684\u5de5\u5177\u6761\uff0c\u66f4\u660e\u786e\u7684\u4e3b\u6b21\u5206\u533a\u3002', currentWorkspace: '\u5f53\u524d\u5de5\u4f5c\u533a', + layoutHint: + '\u53bb\u6389\u72ec\u7acb\u5927\u6a2a\u5e45\u540e\uff0c\u8ba9\u521b\u4f5c\u533a\u57df\u76f4\u63a5\u94fa\u5f00\u5728\u9875\u9762\u4e3b\u89c6\u533a\uff0c\u8d28\u611f\u66f4\u96c6\u4e2d\u3002', switchSection: '\u5207\u6362\u677f\u5757', switchHint: '\u53c2\u8003\u4e0d\u540c\u521b\u4f5c\u9875\u9762\u7684\u5e03\u5c40\u7279\u5f81\uff0c\u7edf\u4e00\u6210\u540c\u4e00\u5957\u521b\u4f5c\u4e2d\u5fc3\u98ce\u683c\u3002', @@ -148,6 +148,8 @@ const panelClassName = const subtleCardClassName = 'rounded-[26px] border border-slate-200/80 bg-white/88 shadow-[0_12px_30px_rgba(15,23,42,0.05)]'; +const workspaceHeightClassName = 'min-h-[calc(100vh-150px)]'; + const toolButtonClassName = '!rounded-2xl !border-slate-200 !bg-white/90 !text-slate-600 hover:!bg-slate-50 hover:!text-slate-900'; @@ -239,7 +241,7 @@ const ResultEmpty = ({ icon, title, description, note, actions }) => ( ); const ChatWorkspace = () => ( -
+
@@ -278,7 +280,7 @@ const ChatWorkspace = () => (
-
+
@@ -360,8 +362,10 @@ const ChatWorkspace = () => ( ); const ImageWorkspace = () => ( -
- +
+
@@ -409,7 +413,7 @@ const ImageWorkspace = () => (
- + ( ); const VideoWorkspace = () => ( -
-
+
+
@@ -478,8 +484,8 @@ const VideoWorkspace = () => (
-
- +
+ { const currentSection = SECTIONS.find((section) => section.key === activeSection) || SECTIONS[0]; - const CurrentIcon = currentSection.icon; - return ( -
-
- -
-
-
-
-
- - {TEXT.creationCenter} - - - {TEXT.unifiedStudio} - - - {TEXT.headerDesc} - -
- -
-
-
-
- -
-
- - {currentSection.title} - - - {TEXT.currentWorkspace} - -
-
-
-
-
-
-
- - -
+
+
+
-
- - {TEXT.switchSection} +
+ + {TEXT.creationCenter} + + + {TEXT.unifiedStudio} + + + {TEXT.layoutHint} +
- + +
+
+ + {TEXT.switchSection} +
+ {currentSection.title} +
+ + {TEXT.switchHint} From 8341c85d27156d8c8eab2ca26c54e7432c80d16e Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 11:03:30 +0800 Subject: [PATCH 038/282] feat: sync creation center models by tags --- web/src/pages/CreationCenter/index.jsx | 289 +++++++++++++++++++++++-- 1 file changed, 273 insertions(+), 16 deletions(-) diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx index f6671d05add7..bb963546d1a2 100644 --- a/web/src/pages/CreationCenter/index.jsx +++ b/web/src/pages/CreationCenter/index.jsx @@ -17,8 +17,16 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState } from 'react'; -import { Avatar, Button, Card, Tag, Typography } from '@douyinfe/semi-ui'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Avatar, + Button, + Card, + Select, + Spin, + Tag, + Typography, +} from '@douyinfe/semi-ui'; import { Clapperboard, Eye, @@ -34,6 +42,7 @@ import { Upload, Wand2, } from 'lucide-react'; +import { API } from '../../helpers'; const TEXT = { creationCenter: '\u521b\u4f5c\u4e2d\u5fc3', @@ -110,6 +119,30 @@ const TEXT = { imageWorkspace: '\u56fe\u7247\u5de5\u4f5c\u533a', videoWorkspace: '\u89c6\u9891\u5de5\u4f5c\u533a', currentAreaTag: '\u5f53\u524d\u4e3a\u4f4e\u4fdd\u771f\u4f46\u9ad8\u8d28\u611f\u7684\u7ed3\u6784\u7a3f\uff0c\u7528\u4e8e\u7ee7\u7eed\u63a5\u5165\u771f\u5b9e\u529f\u80fd\u3002', + loadingModels: '\u6b63\u5728\u540c\u6b65\u6a21\u578b\u6807\u7b7e...', + selectModel: '\u9009\u62e9\u6a21\u578b', + syncedModels: '\u5df2\u540c\u6b65\u6a21\u578b', + noTaggedModels: '\u6682\u65e0\u5df2\u6807\u8bb0\u6a21\u578b', + noTaggedModelsHint: + '\u8bf7\u5148\u53bb\u300c\u6a21\u578b\u7ba1\u7406\u300d\u4e3a\u6a21\u578b\u6253\u4e0a\u5bf9\u5e94\u6807\u7b7e\uff0c\u521b\u4f5c\u4e2d\u5fc3\u4f1a\u81ea\u52a8\u540c\u6b65\u3002', + chatEmptyTitle: '\u6682\u65e0\u6587\u672c\u6a21\u578b', + chatEmptyHint: + '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u6587\u672c\u300d\u6807\u7b7e\u540e\uff0c\u8fd9\u91cc\u4f1a\u81ea\u52a8\u51fa\u73b0\u53ef\u7528\u5bf9\u8bdd\u6a21\u578b\u3002', + imageEmptyTitle: '\u6682\u65e0\u56fe\u7247\u6a21\u578b', + imageEmptyHint: + '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u56fe\u7247\u300d\u6807\u7b7e\u540e\uff0c\u56fe\u7247\u521b\u4f5c\u677f\u5757\u4f1a\u81ea\u52a8\u4f7f\u7528\u8fd9\u4e9b\u6a21\u578b\u3002', + videoEmptyTitle: '\u6682\u65e0\u89c6\u9891\u6a21\u578b', + videoEmptyHint: + '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u89c6\u9891\u300d\u6807\u7b7e\u540e\uff0c\u89c6\u9891\u521b\u4f5c\u677f\u5757\u4f1a\u81ea\u52a8\u540c\u6b65\u6a21\u578b\u5217\u8868\u3002', + textTag: '\u6587\u672c', + imageTag: '\u56fe\u7247', + videoTag: '\u89c6\u9891', +}; + +const MODEL_TAG_MAP = { + chat: TEXT.textTag, + image: TEXT.imageTag, + video: TEXT.videoTag, }; const SECTIONS = [ @@ -155,6 +188,29 @@ const toolButtonClassName = const stepItems = [TEXT.uploadImage, TEXT.enterPrompt, TEXT.generate]; +const splitModelTags = (tags) => + String(tags || '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + +const getSectionModels = (models, sectionKey) => + (Array.isArray(models) ? models : []).filter((model) => { + if (!model || model.status !== 1) { + return false; + } + const tags = splitModelTags(model.tags); + return tags.includes(MODEL_TAG_MAP[sectionKey]); + }); + +const toModelOptions = (models) => + (Array.isArray(models) ? models : []).map((model) => ({ + label: model.vendor_id + ? `${model.model_name} · ID ${model.vendor_id}` + : model.model_name, + value: model.model_name, + })); + const SurfaceLabel = ({ children }) => ( {children} @@ -199,6 +255,42 @@ const StatusSteps = () => (
); +const ModelSelectField = ({ + label, + value, + options, + onChange, + loading = false, +}) => ( +
+ {label} +
{TEXT.defaultMode} @@ -282,6 +388,13 @@ const ChatWorkspace = () => (
+ {!loadingModels && modelOptions.length === 0 ? ( + + ) : null}
@@ -331,7 +444,7 @@ const ChatWorkspace = () => ( 1x
- gpt-4o + {selectedModel || TEXT.selectModel}
@@ -361,7 +474,12 @@ const ChatWorkspace = () => (
); -const ImageWorkspace = () => ( +const ImageWorkspace = ({ + modelOptions, + selectedModel, + onSelectModel, + loadingModels, +}) => (
@@ -379,15 +497,25 @@ const ImageWorkspace = () => (
+ {!loadingModels && modelOptions.length === 0 ? ( + + ) : null} -
@@ -425,7 +553,12 @@ const ImageWorkspace = () => (
); -const VideoWorkspace = () => ( +const VideoWorkspace = ({ + modelOptions, + selectedModel, + onSelectModel, + loadingModels, +}) => (
@@ -444,9 +577,19 @@ const VideoWorkspace = () => (
- + ) : null} + ( const CreationCenter = () => { const [activeSection, setActiveSection] = useState('chat'); + const [models, setModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(true); + const [selectedModels, setSelectedModels] = useState({ + chat: undefined, + image: undefined, + video: undefined, + }); + + useEffect(() => { + let mounted = true; + + const loadModels = async () => { + setLoadingModels(true); + try { + const res = await API.get('/api/models/?page_size=1000'); + const { success, data } = res.data || {}; + if (!mounted) { + return; + } + if (success) { + const items = data?.items || data || []; + setModels(Array.isArray(items) ? items : []); + } else { + setModels([]); + } + } catch (_) { + if (mounted) { + setModels([]); + } + } finally { + if (mounted) { + setLoadingModels(false); + } + } + }; + + loadModels(); + + return () => { + mounted = false; + }; + }, []); + + const sectionModels = useMemo( + () => ({ + chat: getSectionModels(models, 'chat'), + image: getSectionModels(models, 'image'), + video: getSectionModels(models, 'video'), + }), + [models], + ); + + const sectionOptions = useMemo( + () => ({ + chat: toModelOptions(sectionModels.chat), + image: toModelOptions(sectionModels.image), + video: toModelOptions(sectionModels.video), + }), + [sectionModels], + ); + + useEffect(() => { + setSelectedModels((prev) => { + const next = { ...prev }; + ['chat', 'image', 'video'].forEach((key) => { + const options = sectionOptions[key]; + const currentExists = options.some( + (option) => option.value === prev[key], + ); + next[key] = currentExists ? prev[key] : options[0]?.value; + }); + return next; + }); + }, [sectionOptions]); const currentSection = SECTIONS.find((section) => section.key === activeSection) || SECTIONS[0]; + const handleSelectModel = (sectionKey, value) => { + setSelectedModels((prev) => ({ + ...prev, + [sectionKey]: value, + })); + }; + return (
@@ -587,6 +811,16 @@ const CreationCenter = () => { > {section.subtitle}
+
+ + {loadingModels + ? TEXT.loadingModels + : `${TEXT.syncedModels} ${sectionOptions[section.key].length}`} + +
@@ -597,9 +831,32 @@ const CreationCenter = () => {
- {activeSection === 'chat' ? : null} - {activeSection === 'image' ? : null} - {activeSection === 'video' ? : null} + + {activeSection === 'chat' ? ( + handleSelectModel('chat', value)} + loadingModels={loadingModels} + /> + ) : null} + {activeSection === 'image' ? ( + handleSelectModel('image', value)} + loadingModels={loadingModels} + /> + ) : null} + {activeSection === 'video' ? ( + handleSelectModel('video', value)} + loadingModels={loadingModels} + /> + ) : null} +
From 1cd0a21e4828a7c995ab21ad624820eedf2fdae8 Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 11:50:03 +0800 Subject: [PATCH 039/282] revert: remove creation center page --- web/src/App.jsx | 9 - web/src/components/layout/SiderBar.jsx | 12 - web/src/helpers/render.jsx | 3 - web/src/hooks/common/useSidebar.js | 1 - web/src/pages/CreationCenter/index.jsx | 867 ------------------------- 5 files changed, 892 deletions(-) delete mode 100644 web/src/pages/CreationCenter/index.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index 90febf7088a9..a5d1ebc00b32 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -43,7 +43,6 @@ import Pricing from './pages/Pricing'; import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; -import CreationCenter from './pages/CreationCenter'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; @@ -148,14 +147,6 @@ function App() { } /> - - - - } - /> {} }) => { const chatMenuItems = useMemo(() => { const items = [ - { - text: t('创作中心'), - itemKey: 'creation_hidden', - to: '/creation', - className: 'tableHiddle', - }, { text: t('操练场'), itemKey: 'playground', to: '/playground', }, - { - text: t('创作中心'), - itemKey: 'creation', - to: '/creation', - }, { text: t('聊天'), itemKey: 'chat', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 2896d3b1b7a0..28da657f472e 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -60,7 +60,6 @@ import { import { LayoutDashboard, - Sparkles, TerminalSquare, MessageSquare, Key, @@ -119,8 +118,6 @@ export function getLucideIcon(key, selected = false) { switch (key) { case 'detail': return ; - case 'creation': - return ; case 'playground': return ; case 'chat': diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 68a3f9c9709c..cd74ada20280 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -28,7 +28,6 @@ const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; export const DEFAULT_ADMIN_CONFIG = { chat: { enabled: true, - creation: true, playground: true, chat: true, }, diff --git a/web/src/pages/CreationCenter/index.jsx b/web/src/pages/CreationCenter/index.jsx deleted file mode 100644 index bb963546d1a2..000000000000 --- a/web/src/pages/CreationCenter/index.jsx +++ /dev/null @@ -1,867 +0,0 @@ -/* -Copyright (C) 2025 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ - -import React, { useEffect, useMemo, useState } from 'react'; -import { - Avatar, - Button, - Card, - Select, - Spin, - Tag, - Typography, -} from '@douyinfe/semi-ui'; -import { - Clapperboard, - Eye, - Image as ImageIcon, - ImagePlus, - LayoutPanelLeft, - MessageSquareText, - Plus, - Send, - Settings2, - SlidersHorizontal, - Sparkles, - Upload, - Wand2, -} from 'lucide-react'; -import { API } from '../../helpers'; - -const TEXT = { - creationCenter: '\u521b\u4f5c\u4e2d\u5fc3', - unifiedStudio: '\u7edf\u4e00\u521b\u4f5c\u5de5\u4f5c\u53f0', - currentWorkspace: '\u5f53\u524d\u5de5\u4f5c\u533a', - layoutHint: - '\u53bb\u6389\u72ec\u7acb\u5927\u6a2a\u5e45\u540e\uff0c\u8ba9\u521b\u4f5c\u533a\u57df\u76f4\u63a5\u94fa\u5f00\u5728\u9875\u9762\u4e3b\u89c6\u533a\uff0c\u8d28\u611f\u66f4\u96c6\u4e2d\u3002', - switchSection: '\u5207\u6362\u677f\u5757', - switchHint: - '\u53c2\u8003\u4e0d\u540c\u521b\u4f5c\u9875\u9762\u7684\u5e03\u5c40\u7279\u5f81\uff0c\u7edf\u4e00\u6210\u540c\u4e00\u5957\u521b\u4f5c\u4e2d\u5fc3\u98ce\u683c\u3002', - chat: '\u667a\u80fd\u5bf9\u8bdd', - image: '\u56fe\u7247\u521b\u4f5c', - video: '\u89c6\u9891\u521b\u4f5c', - chatSub: - '\u50cf\u53c2\u8003\u56fe\u4e00\u90a3\u6837\u4fdd\u7559\u5927\u9762\u79ef\u4f1a\u8bdd\u7a7a\u95f4\uff0c\u4f46\u7edf\u4e00\u5230\u521b\u4f5c\u4e2d\u5fc3\u7684\u5de5\u4f5c\u53f0\u8bed\u8a00\u91cc\u3002', - imageSub: - '\u53c2\u8003\u56fe\u4e8c\u7684\u53cc\u680f\u5e03\u5c40\uff0c\u5de6\u4fa7\u53c2\u6570\u8bbe\u7f6e\uff0c\u53f3\u4fa7\u751f\u6210\u7ed3\u679c\uff0c\u6574\u4f53\u89c6\u89c9\u66f4\u514b\u5236\u7edf\u4e00\u3002', - videoSub: - '\u53c2\u8003\u56fe\u4e09\u7684\u53cc\u680f\u5e03\u5c40\uff0c\u4fdd\u7559\u89c6\u9891\u7ed3\u679c\u548c\u72b6\u6001\u533a\uff0c\u540c\u65f6\u7edf\u4e00\u5361\u7247\u548c\u5c42\u6b21\u3002', - newChat: '\u65b0\u5bf9\u8bdd', - defaultMode: '\u9ed8\u8ba4\u6a21\u5f0f', - assistant: 'Assistant', - hello: '\u4f60\u597d', - assistantReply: '\u4f60\u597d\uff0c\u8bf7\u95ee\u6709\u4ec0\u4e48\u53ef\u4ee5\u5e2e\u52a9\u60a8\u7684\u5417\uff1f', - inputPlaceholder: - '\u8f93\u5165\u60a8\u7684\u6d88\u606f...(Enter\u53d1\u9001\uff0cShift+Enter \u6362\u884c)', - inputHint: - '\u6309 Enter \u53d1\u9001\uff0cShift+Enter \u6362\u884c\uff0c\u652f\u6301\u62d6\u62fd\u4e0a\u4f20\u56fe\u7247\u6216 Ctrl+V \u7c98\u8d34\u56fe\u7247', - imageSettings: '\u751f\u6210\u8bbe\u7f6e', - imageSettingsDesc: - '\u53c2\u8003\u56fe\u4e8c\u7684\u64cd\u4f5c\u6d41\u7a0b\uff0c\u7edf\u4e00\u4e3a\u66f4\u8f7b\u76c8\u7684\u521b\u4f5c\u5de5\u4f5c\u53f0\u6837\u5f0f\u3002', - videoSettings: '\u89c6\u9891\u751f\u6210', - videoSettingsDesc: - '\u6cbf\u7528\u53c2\u8003\u56fe\u4e09\u7684\u7ed3\u6784\uff0c\u628a\u53c2\u6570\u533a\uff0c\u63d0\u793a\u533a\u548c\u52a8\u4f5c\u6309\u94ae\u505a\u6210\u7edf\u4e00\u5361\u7247\u7cfb\u7edf\u3002', - uploadRef: '\u4e0a\u4f20\u53c2\u8003\u56fe\uff08\u53ef\u9009\uff09', - selectImage: '\u9009\u62e9\u56fe\u7247', - clear: '\u6e05\u7a7a', - uploadHint: - '\u5982\u679c\u4f20\u9012\u4e86\u56fe\u751f\u56fe\u6a21\u578b\uff0c\u53c2\u8003\u56fe\u624d\u4f1a\u751f\u6548\uff1b\u5426\u5219\u53ea\u4f20 prompt \u4e5f\u53ef\u4ee5\u751f\u6210\u3002', - model: '\u6a21\u578b', - prompt: 'Prompt', - promptPlaceholder: '\u63cf\u8ff0\u4f60\u60f3\u751f\u6210\u7684\u5185\u5bb9...', - count: '\u751f\u6210\u6570\u91cf', - note: '\u8bf4\u660e', - oneResult: '\u5c06\u4ea7\u751f 1 \u4e2a\u7ed3\u679c', - imageCountHint: - '\u6700\u591a\u540c\u65f6\u751f\u6210 4 \u5f20\uff0c\u5efa\u8bae 1-2 \u5f20\u3002', - createImageTask: '\u521b\u5efa\u751f\u6210\u4efb\u52a1', - result: '\u751f\u6210\u7ed3\u679c', - startCreating: '\u7b80\u5355\u4e09\u6b65\u5f00\u59cb\u521b\u4f5c', - imageResultNote: - '\u7ed3\u679c\u753b\u5e03\u4fdd\u6301\u6e05\u723d\u7559\u767d\uff0c\u540e\u7eed\u53ef\u4ee5\u65e0\u7f1d\u63a5\u5165\u771f\u5b9e\u751f\u6210\u9884\u89c8\u3002', - copyLink: '\u590d\u5236\u94fe\u63a5', - downloadImage: '\u4e0b\u8f7d\u56fe\u7247', - videoResult: '\u89c6\u9891\u7ed3\u679c', - downloadVideo: '\u4e0b\u8f7d\u89c6\u9891', - videoResultNote: - '\u6587\u751f\u89c6\u9891\uff08T2V\uff09\uff1a\u586b\u5199 Prompt \u5373\u53ef\uff0c\u5206\u8fa8\u7387\u5df2\u6309\u6a21\u578b\u63a8\u8350\uff0c\u65e0\u9700\u4e0a\u4f20\u56fe\u7247\u3002', - uploadImage: '\u4e0a\u4f20\u56fe\u7247', - enterPrompt: '\u8f93\u5165\u63d0\u793a\u8bcd', - generate: '\u751f\u6210', - videoPromptPlaceholder: '\u63cf\u8ff0\u4f60\u8981\u751f\u6210\u7684\u89c6\u9891\u5185\u5bb9...', - sizeLabel: '\u5206\u8fa8\u7387 size', - duration: '\u65f6\u957f', - currentT2V: - '\u5f53\u524d\u4e3a\u6587\u751f\u89c6\u9891\uff08T2V\uff09\uff1a\u53ea\u9700\u586b\u5199 Prompt\uff0c\u65e0\u9700\u4e0a\u4f20\u56fe\u7247\u3002', - videoHint: - '\u652f\u6301\u8fde\u7eed\u63d0\u4ea4\u591a\u4e2a\u89c6\u9891\u4efb\u52a1\uff1b\u53f3\u4fa7\u7ed3\u679c\u533a\u4f18\u5148\u5c55\u793a\u6700\u65b0\u5b8c\u6210\u7684\u9884\u89c8\uff0c\u82e5\u6709\u66f4\u65b0\u4efb\u52a1\u4ecd\u5728\u751f\u6210\u4f1a\u663e\u793a\u8fdb\u5ea6\u63d0\u793a\u3002', - createVideoTask: '\u521b\u5efa\u89c6\u9891\u4efb\u52a1', - status: '\u72b6\u6001', - statusHint: - '\u9009\u62e9\u6a21\u578b\u540e\u5c06\u663e\u793a\u8bf4\u660e\uff1b\u63d0\u4ea4\u4efb\u52a1\u540e\u6b64\u5904\u663e\u793a\u8fdb\u5ea6\u63d0\u793a\u3002', - chatWorkspace: '\u5bf9\u8bdd\u5de5\u4f5c\u533a', - imageWorkspace: '\u56fe\u7247\u5de5\u4f5c\u533a', - videoWorkspace: '\u89c6\u9891\u5de5\u4f5c\u533a', - currentAreaTag: '\u5f53\u524d\u4e3a\u4f4e\u4fdd\u771f\u4f46\u9ad8\u8d28\u611f\u7684\u7ed3\u6784\u7a3f\uff0c\u7528\u4e8e\u7ee7\u7eed\u63a5\u5165\u771f\u5b9e\u529f\u80fd\u3002', - loadingModels: '\u6b63\u5728\u540c\u6b65\u6a21\u578b\u6807\u7b7e...', - selectModel: '\u9009\u62e9\u6a21\u578b', - syncedModels: '\u5df2\u540c\u6b65\u6a21\u578b', - noTaggedModels: '\u6682\u65e0\u5df2\u6807\u8bb0\u6a21\u578b', - noTaggedModelsHint: - '\u8bf7\u5148\u53bb\u300c\u6a21\u578b\u7ba1\u7406\u300d\u4e3a\u6a21\u578b\u6253\u4e0a\u5bf9\u5e94\u6807\u7b7e\uff0c\u521b\u4f5c\u4e2d\u5fc3\u4f1a\u81ea\u52a8\u540c\u6b65\u3002', - chatEmptyTitle: '\u6682\u65e0\u6587\u672c\u6a21\u578b', - chatEmptyHint: - '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u6587\u672c\u300d\u6807\u7b7e\u540e\uff0c\u8fd9\u91cc\u4f1a\u81ea\u52a8\u51fa\u73b0\u53ef\u7528\u5bf9\u8bdd\u6a21\u578b\u3002', - imageEmptyTitle: '\u6682\u65e0\u56fe\u7247\u6a21\u578b', - imageEmptyHint: - '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u56fe\u7247\u300d\u6807\u7b7e\u540e\uff0c\u56fe\u7247\u521b\u4f5c\u677f\u5757\u4f1a\u81ea\u52a8\u4f7f\u7528\u8fd9\u4e9b\u6a21\u578b\u3002', - videoEmptyTitle: '\u6682\u65e0\u89c6\u9891\u6a21\u578b', - videoEmptyHint: - '\u7ed9\u6a21\u578b\u6253\u4e0a\u300c\u89c6\u9891\u300d\u6807\u7b7e\u540e\uff0c\u89c6\u9891\u521b\u4f5c\u677f\u5757\u4f1a\u81ea\u52a8\u540c\u6b65\u6a21\u578b\u5217\u8868\u3002', - textTag: '\u6587\u672c', - imageTag: '\u56fe\u7247', - videoTag: '\u89c6\u9891', -}; - -const MODEL_TAG_MAP = { - chat: TEXT.textTag, - image: TEXT.imageTag, - video: TEXT.videoTag, -}; - -const SECTIONS = [ - { - key: 'chat', - title: TEXT.chat, - subtitle: TEXT.chatSub, - icon: MessageSquareText, - accent: 'from-sky-500 via-cyan-500 to-blue-600', - softAccent: 'from-sky-50 via-cyan-50 to-blue-50', - tagColor: 'blue', - }, - { - key: 'image', - title: TEXT.image, - subtitle: TEXT.imageSub, - icon: ImagePlus, - accent: 'from-fuchsia-500 via-violet-500 to-indigo-600', - softAccent: 'from-fuchsia-50 via-violet-50 to-indigo-50', - tagColor: 'violet', - }, - { - key: 'video', - title: TEXT.video, - subtitle: TEXT.videoSub, - icon: Clapperboard, - accent: 'from-cyan-500 via-sky-500 to-indigo-600', - softAccent: 'from-cyan-50 via-sky-50 to-indigo-50', - tagColor: 'cyan', - }, -]; - -const panelClassName = - 'rounded-[30px] border border-slate-200/80 bg-white/92 shadow-[0_18px_48px_rgba(15,23,42,0.08)] backdrop-blur'; - -const subtleCardClassName = - 'rounded-[26px] border border-slate-200/80 bg-white/88 shadow-[0_12px_30px_rgba(15,23,42,0.05)]'; - -const workspaceHeightClassName = 'min-h-[calc(100vh-150px)]'; - -const toolButtonClassName = - '!rounded-2xl !border-slate-200 !bg-white/90 !text-slate-600 hover:!bg-slate-50 hover:!text-slate-900'; - -const stepItems = [TEXT.uploadImage, TEXT.enterPrompt, TEXT.generate]; - -const splitModelTags = (tags) => - String(tags || '') - .split(',') - .map((tag) => tag.trim()) - .filter(Boolean); - -const getSectionModels = (models, sectionKey) => - (Array.isArray(models) ? models : []).filter((model) => { - if (!model || model.status !== 1) { - return false; - } - const tags = splitModelTags(model.tags); - return tags.includes(MODEL_TAG_MAP[sectionKey]); - }); - -const toModelOptions = (models) => - (Array.isArray(models) ? models : []).map((model) => ({ - label: model.vendor_id - ? `${model.model_name} · ID ${model.vendor_id}` - : model.model_name, - value: model.model_name, - })); - -const SurfaceLabel = ({ children }) => ( - - {children} - -); - -const MockField = ({ label, value, hint, tall = false, action }) => ( -
- {label} -
-
- {value} - {action ? ( - - {action} - - ) : null} -
- {hint ? ( - - {hint} - - ) : null} -
-
-); - -const StatusSteps = () => ( -
- {stepItems.map((item, index) => ( -
-
- {index + 1} -
- {item} -
- ))} -
-); - -const ModelSelectField = ({ - label, - value, - options, - onChange, - loading = false, -}) => ( -
- {label} - -
-
- {TEXT.defaultMode} -
-
-
-
-
-
- -
-
-
-
- {!loadingModels && modelOptions.length === 0 ? ( - - ) : null} -
-
-
- google_QaXdNZGh - 2024-05-14 4:52pm - - G - -
-
- {TEXT.hello} -
-
- - - -
-
-
- -
-
- - A - - {TEXT.assistant} - 2024-05-14 4:52pm -
-
- {TEXT.assistantReply} -
-
- - - - -
-
-
- -
-
-
- default -
-
- 1x -
-
- {selectedModel || TEXT.selectModel} -
-
-
-
- {TEXT.inputPlaceholder} -
-
- - {TEXT.inputHint} - -
-
-
- -
-); - -const ImageWorkspace = ({ - modelOptions, - selectedModel, - onSelectModel, - loadingModels, -}) => ( -
- -
-
- - {TEXT.imageSettings} - - - {TEXT.imageSettingsDesc} - -
- {TEXT.image} -
- -
- {!loadingModels && modelOptions.length === 0 ? ( - - ) : null} - - - -
- - -
-
-
- -
-
-
-
- - - } - /> - -
-); - -const VideoWorkspace = ({ - modelOptions, - selectedModel, - onSelectModel, - loadingModels, -}) => ( -
-
- -
-
- - {TEXT.videoSettings} - - - {TEXT.videoSettingsDesc} - -
- {TEXT.video} -
- -
- {!loadingModels && modelOptions.length === 0 ? ( - - ) : null} - - -
- - -
-
- - {TEXT.currentT2V} - - - {TEXT.videoHint} - -
-
-
- -
-
-
-
-
- -
- - } - /> - - - - {TEXT.status} - - {TEXT.statusHint} - - -
-
-); - -const CreationCenter = () => { - const [activeSection, setActiveSection] = useState('chat'); - const [models, setModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(true); - const [selectedModels, setSelectedModels] = useState({ - chat: undefined, - image: undefined, - video: undefined, - }); - - useEffect(() => { - let mounted = true; - - const loadModels = async () => { - setLoadingModels(true); - try { - const res = await API.get('/api/models/?page_size=1000'); - const { success, data } = res.data || {}; - if (!mounted) { - return; - } - if (success) { - const items = data?.items || data || []; - setModels(Array.isArray(items) ? items : []); - } else { - setModels([]); - } - } catch (_) { - if (mounted) { - setModels([]); - } - } finally { - if (mounted) { - setLoadingModels(false); - } - } - }; - - loadModels(); - - return () => { - mounted = false; - }; - }, []); - - const sectionModels = useMemo( - () => ({ - chat: getSectionModels(models, 'chat'), - image: getSectionModels(models, 'image'), - video: getSectionModels(models, 'video'), - }), - [models], - ); - - const sectionOptions = useMemo( - () => ({ - chat: toModelOptions(sectionModels.chat), - image: toModelOptions(sectionModels.image), - video: toModelOptions(sectionModels.video), - }), - [sectionModels], - ); - - useEffect(() => { - setSelectedModels((prev) => { - const next = { ...prev }; - ['chat', 'image', 'video'].forEach((key) => { - const options = sectionOptions[key]; - const currentExists = options.some( - (option) => option.value === prev[key], - ); - next[key] = currentExists ? prev[key] : options[0]?.value; - }); - return next; - }); - }, [sectionOptions]); - - const currentSection = - SECTIONS.find((section) => section.key === activeSection) || SECTIONS[0]; - - const handleSelectModel = (sectionKey, value) => { - setSelectedModels((prev) => ({ - ...prev, - [sectionKey]: value, - })); - }; - - return ( -
-
-
- -
- - {TEXT.creationCenter} - - - {TEXT.unifiedStudio} - - - {TEXT.layoutHint} - -
- -
-
- - {TEXT.switchSection} -
- {currentSection.title} -
- - - {TEXT.switchHint} - - -
- {SECTIONS.map((section) => { - const Icon = section.icon; - const isActive = activeSection === section.key; - return ( - - ); - })} -
-
- -
- - {activeSection === 'chat' ? ( - handleSelectModel('chat', value)} - loadingModels={loadingModels} - /> - ) : null} - {activeSection === 'image' ? ( - handleSelectModel('image', value)} - loadingModels={loadingModels} - /> - ) : null} - {activeSection === 'video' ? ( - handleSelectModel('video', value)} - loadingModels={loadingModels} - /> - ) : null} - -
-
-
-
- ); -}; - -export default CreationCenter; From 490aee149c22730e7077b8122fe8935c78332f37 Mon Sep 17 00:00:00 2001 From: link87ss Date: Mon, 30 Mar 2026 14:27:47 +0800 Subject: [PATCH 040/282] feat: add creative center page --- web/src/App.jsx | 9 + web/src/components/layout/PageLayout.jsx | 1 + web/src/hooks/common/useNavigation.js | 15 +- web/src/pages/CreativeCenter/index.jsx | 665 ++++++++++++++++++ .../Operation/SettingsHeaderNavModules.jsx | 61 +- 5 files changed, 714 insertions(+), 37 deletions(-) create mode 100644 web/src/pages/CreativeCenter/index.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc00b32..04b3956e22e2 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -53,6 +53,7 @@ import SetupCheck from './components/layout/SetupCheck'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const About = lazy(() => import('./pages/About')); +const CreativeCenter = lazy(() => import('./pages/CreativeCenter')); const UserAgreement = lazy(() => import('./pages/UserAgreement')); const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); @@ -106,6 +107,14 @@ function App() { } /> + } key={location.pathname}> + + + } + /> } /> { '/console/task', '/console/models', '/pricing', + '/creative-center', ]; const shouldHideFooter = cardProPages.includes(location.pathname); diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js index f7e61a203a8e..524c73af7812 100644 --- a/web/src/hooks/common/useNavigation.js +++ b/web/src/hooks/common/useNavigation.js @@ -21,17 +21,19 @@ import { useMemo } from 'react'; export const useNavigation = (t, docsLink, headerNavModules) => { const mainNavLinks = useMemo(() => { - // 默认配置,如果没有传入配置则显示所有模块 const defaultModules = { home: true, + creativeCenter: true, console: true, pricing: true, docs: true, about: true, }; - // 使用传入的配置或默认配置 - const modules = headerNavModules || defaultModules; + const modules = { + ...defaultModules, + ...headerNavModules, + }; const allLinks = [ { @@ -39,6 +41,11 @@ export const useNavigation = (t, docsLink, headerNavModules) => { itemKey: 'home', to: '/', }, + { + text: t('创作中心'), + itemKey: 'creativeCenter', + to: '/creative-center', + }, { text: t('控制台'), itemKey: 'console', @@ -66,13 +73,11 @@ export const useNavigation = (t, docsLink, headerNavModules) => { }, ]; - // 根据配置过滤导航链接 return allLinks.filter((link) => { if (link.itemKey === 'docs') { return docsLink && modules.docs; } if (link.itemKey === 'pricing') { - // 支持新的pricing配置格式 return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing; diff --git a/web/src/pages/CreativeCenter/index.jsx b/web/src/pages/CreativeCenter/index.jsx new file mode 100644 index 000000000000..21614909a93b --- /dev/null +++ b/web/src/pages/CreativeCenter/index.jsx @@ -0,0 +1,665 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo, useState } from 'react'; +import { + ArrowUp, + Check, + ChevronDown, + Clock, + Copy, + History, + Image as ImageIcon, + Layers, + Loader2, + MessageSquare, + Plus, + Video, +} from 'lucide-react'; + +const TABS = { + chat: '聊天', + image: '图片', + video: '视频', +}; + +const chatModels = [ + { + id: 'chat1', + name: 'GPT-5.4', + desc: 'GPT-5.4是OpenAI用于复杂专业工作的前沿模型,具备强大的深度推理...', + activeBg: + 'bg-blue-50 border-l-[3px] border-l-blue-600 rounded-r-xl rounded-l-sm', + }, +]; + +const imageModels = [ + { + id: 1, + name: 'Nano Banana Pro', + desc: '谷歌2025年最新视觉增强模型,拥有极其惊艳的文字排版能力...', + icon: '🍌', + activeBg: + 'bg-blue-50 border-l-[3px] border-l-blue-600 rounded-r-xl rounded-l-sm', + }, + { + id: 2, + name: 'Nano Banana 2', + desc: '谷歌推出的视觉模型基础版,针对日常应用场景优化...', + icon: '🌙', + activeBg: + 'bg-blue-50 border-l-[3px] border-l-blue-600 rounded-r-xl rounded-l-sm', + }, +]; + +const videoModels = [ + { + id: 'v1', + name: 'grok-video-3-plus', + desc: 'Grok 推出的 Plus 级视频生成模型,支持多种时长与比例...', + activeBg: + 'bg-blue-50 border-l-[3px] border-l-blue-600 rounded-r-xl rounded-l-sm', + }, +]; + +const imageResolutions = [ + { value: '1K', label: '1K' }, + { value: '2K', label: '2K' }, + { value: '3K', label: '3K' }, +]; + +const durations = ['10秒', '15秒', '20秒', '25秒']; +const ratios = [ + '自动', + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', +]; + +const GPTIcon = ({ size = 24, className = '' }) => ( + + + +); + +const GrokIcon = ({ size = 24, className = '' }) => ( + + + + + +); + +const createDemoImage = (prompt) => { + const safePrompt = (prompt || '创作灵感').slice(0, 48).replace(/[<>&]/g, ''); + const svg = ` + + + + + + + + + + + + + + + + + + LinkSky + 创作中心 Demo Render + 灵感预览 + ${safePrompt} + 上传文件中的页面内容已接入当前站点 + 这里展示的是站内演示图像生成效果 + + 继续创作 + + `; + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; +}; + +const MenuButton = ({ open, children, onClick }) => ( + +); + +export default function CreativeCenter() { + const [activeTab, setActiveTab] = useState(TABS.chat); + const [activeModel, setActiveModel] = useState('chat1'); + const [prompt, setPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [generatedImage, setGeneratedImage] = useState(null); + const [isQuantityOpen, setIsQuantityOpen] = useState(false); + const [quantity, setQuantity] = useState(1); + const [isRatioOpen, setIsRatioOpen] = useState(false); + const [ratio, setRatio] = useState('自动'); + const [isResolutionOpen, setIsResolutionOpen] = useState(false); + const [resolution, setResolution] = useState('2K'); + const [isDurationOpen, setIsDurationOpen] = useState(false); + const [duration, setDuration] = useState('10秒'); + + const displayModels = useMemo(() => { + if (activeTab === TABS.chat) { + return chatModels.map((model) => ({ + ...model, + icon: , + })); + } + if (activeTab === TABS.video) { + return videoModels.map((model) => ({ + ...model, + icon: , + })); + } + return imageModels; + }, [activeTab]); + + const handleSubmit = async () => { + if (!prompt.trim() || isGenerating) { + return; + } + if (activeTab === TABS.image) { + setIsGenerating(true); + setGeneratedImage(null); + await new Promise((resolve) => setTimeout(resolve, 900)); + setGeneratedImage(createDemoImage(prompt)); + setIsGenerating(false); + return; + } + setPrompt(''); + }; + + const switchTab = (tab, modelId) => { + setActiveTab(tab); + setActiveModel(modelId); + setIsQuantityOpen(false); + setIsRatioOpen(false); + setIsResolutionOpen(false); + setIsDurationOpen(false); + }; + + return ( +
+
+
+
+ Logo +

+ LinkSky 创作中心 +

+
+ +
+
switchTab(TABS.chat, 'chat1')} + className={`flex cursor-pointer flex-col items-center gap-1.5 transition-colors ${ + activeTab === TABS.chat + ? 'text-blue-600' + : 'text-slate-400 hover:text-slate-600' + }`} + > + + 聊天 +
+
switchTab(TABS.image, 1)} + className={`flex cursor-pointer flex-col items-center gap-1.5 transition-colors ${ + activeTab === TABS.image + ? 'text-blue-600' + : 'text-slate-400 hover:text-slate-600' + }`} + > + + 图片 +
+
switchTab(TABS.video, 'v1')} + className={`relative flex cursor-pointer flex-col items-center gap-1.5 transition-colors ${ + activeTab === TABS.video + ? 'text-blue-600' + : 'text-slate-400 hover:text-slate-600' + }`} + > +
+
+ +
+ {displayModels.map((model) => ( +
setActiveModel(model.id)} + className={`flex cursor-pointer gap-3 rounded-xl border p-3 transition-all duration-200 ${ + activeModel === model.id + ? model.activeBg || 'border-blue-200 bg-blue-50 shadow-sm' + : 'border-transparent bg-transparent hover:bg-slate-50' + }`} + > +
+ {typeof model.icon === 'string' ? ( + {model.icon} + ) : ( + model.icon + )} +
+
+
+ + {model.name} + +
+

+ {model.desc} +

+
+
+ ))} +
+ +
+
+
+
+
+ 👩‍🦰 +
+
+
+
+
+ 听雨的作家 + + Lv.1 + +
+
在线
+
+
+ +
+
+
+ +
+ {activeTab === TABS.chat && ( +
+ + +
+ )} + +
+ {activeTab === TABS.chat ? ( +
+
+ +
+
+

+ GPT-5.4是OpenAI用于复杂专业工作的前沿模型,具备强大的深度推理、多模态理解和工具调用能力, +
+ 适用于高难度分析、代码开发与创意写作。 +

+
+
+ ) : activeTab === TABS.video ? ( +
+
+ +
+
+

+ Grok 推出的 Plus 级视频生成模型,支持多种时长,覆盖 16:9、9:16、 +
+ 3:2、2:3、1:1 全比例,适合社交媒体和创意短片场景。 +

+
+
+ ) : ( +
+ {isGenerating ? ( +
+ +

+ 调用 Imagen 4.0 生成中... +

+
+ ) : generatedImage ? ( +
+ Generated +
+ +
+
+ ) : ( + <> +
+
+ 🍌 +
+
+

+ 谷歌2025年最新视觉增强模型,拥有极其惊艳的文字排版能力, +
+ 擅长生成绚烂摄影、幽默风格与复杂视觉设计。 +

+
+ + )} +
+ )} +
+ +
+
+
+ {activeTab !== TABS.chat && ( + + )} +
- {activeTab !== TABS.chat && ( + {activeTab !== '聊天' && ( <>
- setIsQuantityOpen((open) => !open)} + {isQuantityOpen && (
@@ -513,24 +501,35 @@ export default function CreativeCenter() {
setIsQuantityOpen(false)} - /> + >
)}
-
- setIsRatioOpen((open) => !open)} + {isRatioOpen && (
- {ratios.map((option) => ( + {[ + '自动', + '1:1', + '2:3', + '3:2', + '3:4', + '4:3', + '4:5', + '5:4', + '9:16', + '16:9', + '21:9', + ].map((option) => (
)}
- - {activeTab === TABS.video ? ( + {activeTab === '视频' ? (
- setIsDurationOpen((open) => !open)} + {isDurationOpen && (
{durations.map((option) => ( @@ -586,23 +584,23 @@ export default function CreativeCenter() {
setIsDurationOpen(false)} - /> + >
)}
) : (
- - setIsResolutionOpen((open) => !open) + setIsResolutionOpen(!isResolutionOpen) } + className='flex items-center gap-1.5 rounded-xl border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:bg-slate-100' > {resolution} - + {isResolutionOpen && (
{imageResolutions.map((option) => ( @@ -624,7 +622,7 @@ export default function CreativeCenter() {
setIsResolutionOpen(false)} - /> + >
)}
@@ -652,7 +650,7 @@ export default function CreativeCenter() { + + +
+
+
+
${safeTitle}
+
独立预览页播放,不占用创作中心当前页面
+
+
+
+ +
+
+ +`); + previewWindow.document.close(); +}; + const getVideoTaskMediaUrl = (task) => { if (typeof task?.url === 'string' && task.url.trim()) { return task.url.trim(); @@ -1016,7 +1120,6 @@ export default function App() { const [collapsedImageRecordIds, setCollapsedImageRecordIds] = useState({}); const [selectedImageTaskIds, setSelectedImageTaskIds] = useState({}); const [previewImage, setPreviewImage] = useState(null); - const [previewVideo, setPreviewVideo] = useState(null); const [collapsedVideoRecordIds, setCollapsedVideoRecordIds] = useState({}); const [selectedVideoTaskIds, setSelectedVideoTaskIds] = useState({}); const [progressClock, setProgressClock] = useState(() => Date.now()); @@ -2431,13 +2534,6 @@ const getCreativeVideoCardObjectFitClass = (record) => textareaRef.current?.focus(); }; - const openVideoPreview = (url, title = '视频预览') => { - if (!url) { - return; - } - setPreviewVideo({ url, title }); - }; - const handleClearImageResults = async () => { setImageRecords([]); setCollapsedImageRecordIds({}); @@ -3694,7 +3790,7 @@ const getCreativeVideoCardObjectFitClass = (record) => />
) : null} - {previewVideo ? ( -
-
- -
- {previewVideo.title || '视频预览'} -
-
-
-
-
- ) : null} -