部分上游模型不允许同时设置 temperature 和 top_p,新增管理员可配置的 模型前缀列表,匹配的模型在 Playground 中自动互斥切换两个参数。 - 后端新增 PlaygroundSetting 配置 + GET /api/playground/config 公开端点 - 运营设置页面新增 Playground 互斥模型前缀编辑 textarea - Playground 自动检测模型名匹配,启用一个参数自动禁用另一个 - 默认包含 deepseek-v4-flash/pro、deepseek-reasoner、o1-/o3-/o4-、gpt-5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>master
| @@ -4,9 +4,11 @@ import ( | |||
| "errors" | |||
| "fmt" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/middleware" | |||
| "github.com/QuantumNous/new-api/model" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/QuantumNous/new-api/setting/operation_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/gin-gonic/gin" | |||
| @@ -54,3 +56,11 @@ func Playground(c *gin.Context) { | |||
| Relay(c, types.RelayFormatOpenAI) | |||
| } | |||
| // GetPlaygroundConfig 获取 Playground 公开配置(无需认证) | |||
| func GetPlaygroundConfig(c *gin.Context) { | |||
| setting := operation_setting.GetPlaygroundSetting() | |||
| common.ApiSuccess(c, gin.H{ | |||
| "mutual_exclusive_params": setting.MutualExclusiveParams, | |||
| }) | |||
| } | |||
| @@ -48,6 +48,7 @@ func SetApiRouter(router *gin.Engine) { | |||
| // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route | |||
| apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) | |||
| apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) | |||
| apiRouter.GET("/playground/config", controller.GetPlaygroundConfig) | |||
| apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | |||
| apiRouter.POST("/creem/webhook", controller.CreemWebhook) | |||
| @@ -0,0 +1,22 @@ | |||
| package operation_setting | |||
| import "github.com/QuantumNous/new-api/setting/config" | |||
| // PlaygroundSetting Playground 配置 | |||
| type PlaygroundSetting struct { | |||
| MutualExclusiveParams string `json:"mutual_exclusive_params"` // temperature/top_p 互斥的模型前缀列表,换行分隔 | |||
| } | |||
| // 默认配置 | |||
| var playgroundSetting = PlaygroundSetting{ | |||
| MutualExclusiveParams: "deepseek-v4-flash\ndeepseek-v4-pro\ndeepseek-reasoner\no1-\no3-\no4-\ngpt-5", | |||
| } | |||
| func init() { | |||
| config.GlobalConfig.Register("playground_setting", &playgroundSetting) | |||
| } | |||
| // GetPlaygroundSetting 获取 Playground 配置 | |||
| func GetPlaygroundSetting() *PlaygroundSetting { | |||
| return &playgroundSetting | |||
| } | |||
| @@ -75,7 +75,8 @@ export const OptimizedSettingsPanel = React.memo( | |||
| JSON.stringify(prevProps.previewPayload) === | |||
| JSON.stringify(nextProps.previewPayload) && | |||
| JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages) && | |||
| JSON.stringify(prevProps.channels) === JSON.stringify(nextProps.channels) | |||
| JSON.stringify(prevProps.channels) === JSON.stringify(nextProps.channels) && | |||
| prevProps.mutualExclusive === nextProps.mutualExclusive | |||
| ); | |||
| }, | |||
| ); | |||
| @@ -37,12 +37,20 @@ const ParameterControl = ({ | |||
| onInputChange, | |||
| onParameterToggle, | |||
| disabled = false, | |||
| mutualExclusive = false, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <> | |||
| {/* Temperature */} | |||
| {mutualExclusive && ( | |||
| <div className='mb-3 px-2 py-1.5 bg-amber-50 border border-amber-200 rounded-lg'> | |||
| <Typography.Text className='text-xs text-amber-600'> | |||
| {t('此模型的 Temperature 和 Top P 互斥,只能启用一个')} | |||
| </Typography.Text> | |||
| </div> | |||
| )} | |||
| <div | |||
| className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`} | |||
| > | |||
| @@ -36,6 +36,7 @@ const SettingsPanel = ({ | |||
| showDebugPanel, | |||
| customRequestMode, | |||
| customRequestBody, | |||
| mutualExclusive = false, | |||
| onInputChange, | |||
| onParameterToggle, | |||
| onCloseSettings, | |||
| @@ -231,6 +232,7 @@ const SettingsPanel = ({ | |||
| onInputChange={onInputChange} | |||
| onParameterToggle={onParameterToggle} | |||
| disabled={customRequestMode} | |||
| mutualExclusive={mutualExclusive} | |||
| /> | |||
| </div> | |||
| @@ -29,6 +29,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring | |||
| import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; | |||
| import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; | |||
| import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync'; | |||
| import SettingsPlayground from '../../pages/Setting/Operation/SettingsPlayground'; | |||
| import { API, showError, toBoolean } from '../../helpers'; | |||
| const OperationSetting = () => { | |||
| @@ -98,6 +99,9 @@ const OperationSetting = () => { | |||
| /* 令牌设置 */ | |||
| 'token_setting.max_user_tokens': 1000, | |||
| /* Playground 设置 */ | |||
| 'playground_setting.mutual_exclusive_params': '', | |||
| }); | |||
| let [loading, setLoading] = useState(false); | |||
| @@ -181,6 +185,10 @@ const OperationSetting = () => { | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsRegionSync options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| {/* Playground 参数互斥设置 */} | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPlayground options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| </Spin> | |||
| </> | |||
| ); | |||
| @@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import { useState, useCallback, useRef, useEffect } from 'react'; | |||
| import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { | |||
| DEFAULT_MESSAGES, | |||
| @@ -32,7 +32,7 @@ import { | |||
| loadMessages, | |||
| saveMessages, | |||
| } from '../../components/playground/configStorage'; | |||
| import { processIncompleteThinkTags } from '../../helpers'; | |||
| import { processIncompleteThinkTags, API } from '../../helpers'; | |||
| export const usePlaygroundState = () => { | |||
| const { t } = useTranslation(); | |||
| @@ -120,17 +120,70 @@ export const usePlaygroundState = () => { | |||
| const saveConfigTimeoutRef = useRef(null); | |||
| const saveMessagesTimeoutRef = useRef(null); | |||
| // 互斥模型前缀配置 | |||
| const [mutualExclusivePrefixes, setMutualExclusivePrefixes] = useState([]); | |||
| // 加载互斥配置 | |||
| useEffect(() => { | |||
| const fetchConfig = async () => { | |||
| try { | |||
| const res = await API.get('/api/playground/config'); | |||
| const { success, data } = res.data; | |||
| if (success && data?.mutual_exclusive_params) { | |||
| const prefixes = data.mutual_exclusive_params | |||
| .split('\n') | |||
| .map((s) => s.trim()) | |||
| .filter(Boolean); | |||
| setMutualExclusivePrefixes(prefixes); | |||
| } | |||
| } catch (e) { | |||
| // 静默失败,不影响 Playground 使用 | |||
| } | |||
| }; | |||
| fetchConfig(); | |||
| }, []); | |||
| // 判断当前模型是否需要互斥 | |||
| const isMutualExclusiveModel = useCallback( | |||
| (modelName) => { | |||
| if (!modelName || mutualExclusivePrefixes.length === 0) return false; | |||
| return mutualExclusivePrefixes.some((prefix) => | |||
| modelName.toLowerCase().startsWith(prefix.toLowerCase()), | |||
| ); | |||
| }, | |||
| [mutualExclusivePrefixes], | |||
| ); | |||
| const isMutualExclusive = useMemo( | |||
| () => isMutualExclusiveModel(inputs.model), | |||
| [inputs.model, isMutualExclusiveModel], | |||
| ); | |||
| // 配置更新函数 | |||
| const handleInputChange = useCallback((name, value) => { | |||
| setInputs((prev) => ({ ...prev, [name]: value })); | |||
| }, []); | |||
| const handleParameterToggle = useCallback((paramName) => { | |||
| setParameterEnabled((prev) => ({ | |||
| ...prev, | |||
| [paramName]: !prev[paramName], | |||
| })); | |||
| }, []); | |||
| const handleParameterToggle = useCallback( | |||
| (paramName) => { | |||
| setParameterEnabled((prev) => { | |||
| const newVal = !prev[paramName]; | |||
| const updated = { ...prev, [paramName]: newVal }; | |||
| // temperature/top_p 互斥逻辑 | |||
| if (isMutualExclusiveModel(inputs.model)) { | |||
| if (paramName === 'temperature' && newVal) { | |||
| updated.top_p = false; | |||
| } else if (paramName === 'top_p' && newVal) { | |||
| updated.temperature = false; | |||
| } | |||
| } | |||
| return updated; | |||
| }); | |||
| }, | |||
| [inputs.model, isMutualExclusiveModel], | |||
| ); | |||
| // 消息保存函数 - 改为立即保存,可以接受参数 | |||
| const saveMessagesImmediately = useCallback( | |||
| @@ -218,6 +271,18 @@ export const usePlaygroundState = () => { | |||
| }; | |||
| }, []); | |||
| // 模型切换时,若互斥模型且两个参数都启用,自动禁用 top_p | |||
| useEffect(() => { | |||
| if (isMutualExclusiveModel(inputs.model)) { | |||
| setParameterEnabled((prev) => { | |||
| if (prev.temperature && prev.top_p) { | |||
| return { ...prev, top_p: false }; | |||
| } | |||
| return prev; | |||
| }); | |||
| } | |||
| }, [inputs.model, isMutualExclusiveModel]); | |||
| // 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复 | |||
| useEffect(() => { | |||
| if (!Array.isArray(message) || message.length === 0) return; | |||
| @@ -301,6 +366,8 @@ export const usePlaygroundState = () => { | |||
| // 处理函数 | |||
| handleInputChange, | |||
| handleParameterToggle, | |||
| isMutualExclusiveModel, | |||
| isMutualExclusive, | |||
| debouncedSaveConfig, | |||
| saveMessagesImmediately, | |||
| handleConfigImport, | |||
| @@ -104,6 +104,8 @@ const Playground = () => { | |||
| chatRef, | |||
| handleInputChange, | |||
| handleParameterToggle, | |||
| isMutualExclusiveModel, | |||
| isMutualExclusive, | |||
| debouncedSaveConfig, | |||
| saveMessagesImmediately, | |||
| handleConfigImport, | |||
| @@ -537,6 +539,7 @@ const Playground = () => { | |||
| showDebugPanel={showDebugPanel} | |||
| customRequestMode={customRequestMode} | |||
| customRequestBody={customRequestBody} | |||
| mutualExclusive={isMutualExclusive} | |||
| onInputChange={handleInputChange} | |||
| onParameterToggle={handleParameterToggle} | |||
| onCloseSettings={() => setShowSettings(false)} | |||
| @@ -0,0 +1,110 @@ | |||
| import React, { useEffect, useState, useRef } from 'react'; | |||
| import { Button, Col, Form, Row, Spin, Typography } from '@douyinfe/semi-ui'; | |||
| import { | |||
| compareObjects, | |||
| API, | |||
| showError, | |||
| showSuccess, | |||
| showWarning, | |||
| } from '../../../helpers'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export default function SettingsPlayground(props) { | |||
| const { t } = useTranslation(); | |||
| const [loading, setLoading] = useState(false); | |||
| const [inputs, setInputs] = useState({ | |||
| 'playground_setting.mutual_exclusive_params': '', | |||
| }); | |||
| const refForm = useRef(); | |||
| const [inputsRow, setInputsRow] = useState(inputs); | |||
| function handleFieldChange(fieldName) { | |||
| return (value) => { | |||
| setInputs((inputs) => ({ ...inputs, [fieldName]: value })); | |||
| }; | |||
| } | |||
| function onSubmit() { | |||
| const updateArray = compareObjects(inputs, inputsRow); | |||
| if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); | |||
| const requestQueue = updateArray.map((item) => { | |||
| let value = String(inputs[item.key]); | |||
| return API.put('/api/option/', { | |||
| key: item.key, | |||
| value, | |||
| }); | |||
| }); | |||
| setLoading(true); | |||
| Promise.all(requestQueue) | |||
| .then((res) => { | |||
| if (requestQueue.length === 1) { | |||
| if (res.includes(undefined)) return; | |||
| } else if (requestQueue.length > 1) { | |||
| if (res.includes(undefined)) | |||
| return showError(t('部分保存失败,请重试')); | |||
| } | |||
| showSuccess(t('保存成功')); | |||
| props.refresh(); | |||
| }) | |||
| .catch(() => { | |||
| showError(t('保存失败,请重试')); | |||
| }) | |||
| .finally(() => { | |||
| setLoading(false); | |||
| }); | |||
| } | |||
| useEffect(() => { | |||
| const currentInputs = {}; | |||
| for (let key in props.options) { | |||
| if (Object.keys(inputs).includes(key)) { | |||
| currentInputs[key] = props.options[key]; | |||
| } | |||
| } | |||
| setInputs(currentInputs); | |||
| setInputsRow(structuredClone(currentInputs)); | |||
| refForm.current.setValues(currentInputs); | |||
| }, [props.options]); | |||
| return ( | |||
| <> | |||
| <Spin spinning={loading}> | |||
| <Form | |||
| values={inputs} | |||
| getFormApi={(formAPI) => (refForm.current = formAPI)} | |||
| style={{ marginBottom: 15 }} | |||
| > | |||
| <Form.Section text={t('Playground 参数互斥设置')}> | |||
| <Typography.Text | |||
| type='tertiary' | |||
| style={{ marginBottom: 16, display: 'block' }} | |||
| > | |||
| {t( | |||
| '配置 temperature 和 top_p 互斥的模型列表。每行一个模型前缀,匹配的模型在 Playground 中只能启用 temperature 或 top_p 其中一个。', | |||
| )} | |||
| </Typography.Text> | |||
| <Row gutter={16}> | |||
| <Col span={24}> | |||
| <Form.TextArea | |||
| field={'playground_setting.mutual_exclusive_params'} | |||
| label={t('互斥模型前缀列表')} | |||
| placeholder={`deepseek-v4-flash\ndeepseek-v4-pro\ndeepseek-reasoner\no1-\no3-\no4-\ngpt-5`} | |||
| rows={8} | |||
| onChange={handleFieldChange( | |||
| 'playground_setting.mutual_exclusive_params', | |||
| )} | |||
| autosize | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| <Row> | |||
| <Button size='default' onClick={onSubmit}> | |||
| {t('保存 Playground 设置')} | |||
| </Button> | |||
| </Row> | |||
| </Form.Section> | |||
| </Form> | |||
| </Spin> | |||
| </> | |||
| ); | |||
| } | |||