Просмотр исходного кода

feat(playground): temperature/top_p 参数互斥设置

部分上游模型不允许同时设置 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
fengsilin 7 часов назад
Родитель
Сommit
3e86f523db
10 измененных файлов: 241 добавлений и 9 удалений
  1. +10
    -0
      controller/playground.go
  2. +1
    -0
      router/api-router.go
  3. +22
    -0
      setting/operation_setting/playground_setting.go
  4. +2
    -1
      web/src/components/playground/OptimizedComponents.js
  5. +8
    -0
      web/src/components/playground/ParameterControl.jsx
  6. +2
    -0
      web/src/components/playground/SettingsPanel.jsx
  7. +8
    -0
      web/src/components/settings/OperationSetting.jsx
  8. +75
    -8
      web/src/hooks/playground/usePlaygroundState.js
  9. +3
    -0
      web/src/pages/Playground/index.jsx
  10. +110
    -0
      web/src/pages/Setting/Operation/SettingsPlayground.jsx

+ 10
- 0
controller/playground.go Просмотреть файл

@@ -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,
})
}

+ 1
- 0
router/api-router.go Просмотреть файл

@@ -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)


+ 22
- 0
setting/operation_setting/playground_setting.go Просмотреть файл

@@ -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
}

+ 2
- 1
web/src/components/playground/OptimizedComponents.js Просмотреть файл

@@ -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
);
},
);


+ 8
- 0
web/src/components/playground/ParameterControl.jsx Просмотреть файл

@@ -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' : ''}`}
>


+ 2
- 0
web/src/components/playground/SettingsPanel.jsx Просмотреть файл

@@ -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>



+ 8
- 0
web/src/components/settings/OperationSetting.jsx Просмотреть файл

@@ -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>
</>
);


+ 75
- 8
web/src/hooks/playground/usePlaygroundState.js Просмотреть файл

@@ -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,


+ 3
- 0
web/src/pages/Playground/index.jsx Просмотреть файл

@@ -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)}


+ 110
- 0
web/src/pages/Setting/Operation/SettingsPlayground.jsx Просмотреть файл

@@ -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>
</>
);
}

Загрузка…
Отмена
Сохранить