部分上游模型不允许同时设置 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" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/middleware" | "github.com/QuantumNous/new-api/middleware" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | relaycommon "github.com/QuantumNous/new-api/relay/common" | ||||
| "github.com/QuantumNous/new-api/setting/operation_setting" | |||||
| "github.com/QuantumNous/new-api/types" | "github.com/QuantumNous/new-api/types" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| @@ -54,3 +56,11 @@ func Playground(c *gin.Context) { | |||||
| Relay(c, types.RelayFormatOpenAI) | 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 | // Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route | ||||
| apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) | apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) | ||||
| apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) | apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) | ||||
| apiRouter.GET("/playground/config", controller.GetPlaygroundConfig) | |||||
| apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | apiRouter.POST("/stripe/webhook", controller.StripeWebhook) | ||||
| apiRouter.POST("/creem/webhook", controller.CreemWebhook) | 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(prevProps.previewPayload) === | ||||
| JSON.stringify(nextProps.previewPayload) && | JSON.stringify(nextProps.previewPayload) && | ||||
| JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages) && | 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, | onInputChange, | ||||
| onParameterToggle, | onParameterToggle, | ||||
| disabled = false, | disabled = false, | ||||
| mutualExclusive = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| {/* Temperature */} | {/* 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 | <div | ||||
| className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`} | className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`} | ||||
| > | > | ||||
| @@ -36,6 +36,7 @@ const SettingsPanel = ({ | |||||
| showDebugPanel, | showDebugPanel, | ||||
| customRequestMode, | customRequestMode, | ||||
| customRequestBody, | customRequestBody, | ||||
| mutualExclusive = false, | |||||
| onInputChange, | onInputChange, | ||||
| onParameterToggle, | onParameterToggle, | ||||
| onCloseSettings, | onCloseSettings, | ||||
| @@ -231,6 +232,7 @@ const SettingsPanel = ({ | |||||
| onInputChange={onInputChange} | onInputChange={onInputChange} | ||||
| onParameterToggle={onParameterToggle} | onParameterToggle={onParameterToggle} | ||||
| disabled={customRequestMode} | disabled={customRequestMode} | ||||
| mutualExclusive={mutualExclusive} | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| @@ -29,6 +29,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring | |||||
| import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; | import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; | ||||
| import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; | import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; | ||||
| import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync'; | import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync'; | ||||
| import SettingsPlayground from '../../pages/Setting/Operation/SettingsPlayground'; | |||||
| import { API, showError, toBoolean } from '../../helpers'; | import { API, showError, toBoolean } from '../../helpers'; | ||||
| const OperationSetting = () => { | const OperationSetting = () => { | ||||
| @@ -98,6 +99,9 @@ const OperationSetting = () => { | |||||
| /* 令牌设置 */ | /* 令牌设置 */ | ||||
| 'token_setting.max_user_tokens': 1000, | 'token_setting.max_user_tokens': 1000, | ||||
| /* Playground 设置 */ | |||||
| 'playground_setting.mutual_exclusive_params': '', | |||||
| }); | }); | ||||
| let [loading, setLoading] = useState(false); | let [loading, setLoading] = useState(false); | ||||
| @@ -181,6 +185,10 @@ const OperationSetting = () => { | |||||
| <Card style={{ marginTop: '10px' }}> | <Card style={{ marginTop: '10px' }}> | ||||
| <SettingsRegionSync options={inputs} refresh={onRefresh} /> | <SettingsRegionSync options={inputs} refresh={onRefresh} /> | ||||
| </Card> | </Card> | ||||
| {/* Playground 参数互斥设置 */} | |||||
| <Card style={{ marginTop: '10px' }}> | |||||
| <SettingsPlayground options={inputs} refresh={onRefresh} /> | |||||
| </Card> | |||||
| </Spin> | </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 | 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 { useTranslation } from 'react-i18next'; | ||||
| import { | import { | ||||
| DEFAULT_MESSAGES, | DEFAULT_MESSAGES, | ||||
| @@ -32,7 +32,7 @@ import { | |||||
| loadMessages, | loadMessages, | ||||
| saveMessages, | saveMessages, | ||||
| } from '../../components/playground/configStorage'; | } from '../../components/playground/configStorage'; | ||||
| import { processIncompleteThinkTags } from '../../helpers'; | |||||
| import { processIncompleteThinkTags, API } from '../../helpers'; | |||||
| export const usePlaygroundState = () => { | export const usePlaygroundState = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -120,17 +120,70 @@ export const usePlaygroundState = () => { | |||||
| const saveConfigTimeoutRef = useRef(null); | const saveConfigTimeoutRef = useRef(null); | ||||
| const saveMessagesTimeoutRef = 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) => { | const handleInputChange = useCallback((name, value) => { | ||||
| setInputs((prev) => ({ ...prev, [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( | 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 状态,自动修复 | // 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!Array.isArray(message) || message.length === 0) return; | if (!Array.isArray(message) || message.length === 0) return; | ||||
| @@ -301,6 +366,8 @@ export const usePlaygroundState = () => { | |||||
| // 处理函数 | // 处理函数 | ||||
| handleInputChange, | handleInputChange, | ||||
| handleParameterToggle, | handleParameterToggle, | ||||
| isMutualExclusiveModel, | |||||
| isMutualExclusive, | |||||
| debouncedSaveConfig, | debouncedSaveConfig, | ||||
| saveMessagesImmediately, | saveMessagesImmediately, | ||||
| handleConfigImport, | handleConfigImport, | ||||
| @@ -104,6 +104,8 @@ const Playground = () => { | |||||
| chatRef, | chatRef, | ||||
| handleInputChange, | handleInputChange, | ||||
| handleParameterToggle, | handleParameterToggle, | ||||
| isMutualExclusiveModel, | |||||
| isMutualExclusive, | |||||
| debouncedSaveConfig, | debouncedSaveConfig, | ||||
| saveMessagesImmediately, | saveMessagesImmediately, | ||||
| handleConfigImport, | handleConfigImport, | ||||
| @@ -537,6 +539,7 @@ const Playground = () => { | |||||
| showDebugPanel={showDebugPanel} | showDebugPanel={showDebugPanel} | ||||
| customRequestMode={customRequestMode} | customRequestMode={customRequestMode} | ||||
| customRequestBody={customRequestBody} | customRequestBody={customRequestBody} | ||||
| mutualExclusive={isMutualExclusive} | |||||
| onInputChange={handleInputChange} | onInputChange={handleInputChange} | ||||
| onParameterToggle={handleParameterToggle} | onParameterToggle={handleParameterToggle} | ||||
| onCloseSettings={() => setShowSettings(false)} | 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> | |||||
| </> | |||||
| ); | |||||
| } | |||||