Sfoglia il codice sorgente

feat(playground): 添加渠道选择功能,支持指定渠道体验模型

- 新增 /api/user/model_channels 接口,返回模型可用渠道及默认渠道
- Playground 设置面板添加渠道选择下拉框
- Distribute 中间件支持从请求体读取 channel_id 指定渠道
- 支持 URL 参数 ?model=xxx 直接选择模型

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 settimana fa
parent
commit
74cc8c0d56
10 ha cambiato i file con 211 aggiunte e 10 eliminazioni
  1. +62
    -0
      controller/playground_channels.go
  2. +7
    -3
      middleware/distributor.go
  3. +51
    -0
      model/ability.go
  4. +1
    -0
      router/api-router.go
  5. +2
    -1
      web/src/components/playground/OptimizedComponents.js
  6. +33
    -0
      web/src/components/playground/SettingsPanel.jsx
  7. +1
    -0
      web/src/constants/playground.constants.js
  8. +11
    -5
      web/src/hooks/playground/useDataLoader.js
  9. +3
    -0
      web/src/hooks/playground/usePlaygroundState.js
  10. +40
    -1
      web/src/pages/Playground/index.jsx

+ 62
- 0
controller/playground_channels.go Vedi File

@@ -0,0 +1,62 @@
package controller

import (
"net/http"

"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

func GetModelChannels(c *gin.Context) {
modelName := c.Query("model")
if modelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "model parameter is required",
})
return
}

userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "unauthorized",
})
return
}

userCache, err := model.GetUserCache(userId)
if err != nil || userCache == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to get user info",
})
return
}
userGroup := userCache.Group
if userGroup == "" {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to get user group",
})
return
}

channels, defaultChannelId, err := model.GetModelChannelsForGroup(modelName, userGroup)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to query channels",
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"channels": channels,
"default_channel_id": defaultChannelId,
},
})
}

+ 7
- 3
middleware/distributor.go Vedi File

@@ -23,19 +23,20 @@ import (
)

type ModelRequest struct {
Model string `json:"model"`
Group string `json:"group,omitempty"`
Model string `json:"model"`
Group string `json:"group,omitempty"`
ChannelId int `json:"channel_id,omitempty"`
}

func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
var channel *model.Channel
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
modelRequest, shouldSelectChannel, err := getModelRequest(c)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
return
}
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
@@ -368,6 +369,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = req.Model
modelRequest.Group = req.Group
common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group)
if req.ChannelId > 0 {
common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, strconv.Itoa(req.ChannelId))
}
}

if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" {


+ 51
- 0
model/ability.go Vedi File

@@ -66,6 +66,57 @@ func GetAbilitiesByChannelId(channelId int) ([]*Ability, error) {
return abilities, err
}

// GetModelChannelsForGroup 返回指定模型在指定分组下的可用渠道列表及默认渠道ID
func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, int, error) {
var channelIds []int
err := DB.Model(&Ability{}).
Where("model = ?", modelName).
Where("enabled = ?", true).
Where(commonGroupCol+" = ?", group).
Distinct("channel_id").
Pluck("channel_id", &channelIds).Error
if err != nil {
return nil, 0, err
}

if len(channelIds) == 0 {
return []map[string]any{}, 0, nil
}

type channelInfo struct {
Id int `json:"id"`
Name string `json:"name"`
}
var channels []channelInfo
err = DB.Table("channels").
Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled).
Select("id, name").
Find(&channels).Error
if err != nil {
return nil, 0, err
}

defaultChannelId := 0
if defaultChId, ok := GetDefaultChannelId(modelName); ok {
for _, id := range channelIds {
if id == defaultChId {
defaultChannelId = defaultChId
break
}
}
}

result := make([]map[string]any, 0, len(channels))
for _, ch := range channels {
result = append(result, map[string]any{
"id": ch.Id,
"name": ch.Name,
})
}

return result, defaultChannelId, nil
}

func getPriority(group string, model string, retry int) (int, error) {

var priorities []int


+ 1
- 0
router/api-router.go Vedi File

@@ -75,6 +75,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/self/groups", controller.GetUserGroups)
selfRoute.GET("/self", controller.GetSelf)
selfRoute.GET("/models", controller.GetUserModels)
selfRoute.GET("/model_channels", controller.GetModelChannels)
selfRoute.GET("/channels", controller.GetUserChannelsForBinding)
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)


+ 2
- 1
web/src/components/playground/OptimizedComponents.js Vedi File

@@ -74,7 +74,8 @@ export const OptimizedSettingsPanel = React.memo(
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.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)
);
},
);


+ 33
- 0
web/src/components/playground/SettingsPanel.jsx Vedi File

@@ -45,6 +45,7 @@ const SettingsPanel = ({
onCustomRequestBodyChange,
previewPayload,
messages,
channels = [],
}) => {
const { t } = useTranslation();

@@ -176,6 +177,38 @@ const SettingsPanel = ({
/>
</div>

{/* 渠道选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className='flex items-center gap-2 mb-2'>
<Typography.Text strong className='text-sm'>
{t('渠道')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className='text-xs text-orange-600'>
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择渠道')}
name='channelId'
selection
filter={selectFilter}
autoClearSearchValue={false}
onChange={(value) => onInputChange('channelId', value)}
value={inputs.channelId}
autoComplete='new-password'
optionList={channels.map((ch) => ({
value: ch.id,
label: ch.name,
}))}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className='!rounded-lg'
disabled={customRequestMode || channels.length === 0}
/>
</div>

{/* 图片URL输入 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<ImageUrlInput


+ 1
- 0
web/src/constants/playground.constants.js Vedi File

@@ -85,6 +85,7 @@ export const DEFAULT_CONFIG = {
inputs: {
model: 'gpt-4o',
group: '',
channelId: 0,
temperature: 0.7,
top_p: 1,
max_tokens: 4096,


+ 11
- 5
web/src/hooks/playground/useDataLoader.js Vedi File

@@ -28,6 +28,7 @@ export const useDataLoader = (
handleInputChange,
setModels,
setGroups,
searchParams,
) => {
const { t } = useTranslation();

@@ -37,10 +38,15 @@ export const useDataLoader = (
const { success, message, data } = res.data;

if (success) {
const { modelOptions, selectedModel } = processModelsData(
data,
inputs.model,
);
let selectedModel = inputs.model;
const urlModel = searchParams?.get('model');
if (urlModel) {
const modelNames = data || [];
if (Array.isArray(modelNames) && modelNames.includes(urlModel)) {
selectedModel = urlModel;
}
}
const { modelOptions } = processModelsData(data, selectedModel);
setModels(modelOptions);

if (selectedModel !== inputs.model) {
@@ -52,7 +58,7 @@ export const useDataLoader = (
} catch (error) {
showError(t('加载模型失败'));
}
}, [inputs.model, handleInputChange, setModels, t]);
}, [inputs.model, handleInputChange, setModels, t, searchParams]);

const loadGroups = useCallback(async () => {
try {


+ 3
- 0
web/src/hooks/playground/usePlaygroundState.js Vedi File

@@ -83,6 +83,7 @@ export const usePlaygroundState = () => {
const [showSettings, setShowSettings] = useState(false);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [channels, setChannels] = useState([]);
const [status, setStatus] = useState({});

// 消息相关状态 - 使用加载的消息或默认消息初始化
@@ -287,6 +288,8 @@ export const usePlaygroundState = () => {
setShowSettings,
setModels,
setGroups,
channels,
setChannels,
setStatus,
setMessage,
setDebugData,


+ 40
- 1
web/src/pages/Playground/index.jsx Vedi File

@@ -48,6 +48,7 @@ import {
getTextContent,
buildApiPayload,
encodeToBase64,
API,
} from '../../helpers';

// Components
@@ -110,6 +111,8 @@ const Playground = () => {
setShowSettings,
setModels,
setGroups,
channels,
setChannels,
setStatus,
setMessage,
setDebugData,
@@ -130,7 +133,39 @@ const Playground = () => {
);

// 数据加载
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups, searchParams);

// Load channels for the selected model
useEffect(() => {
if (!inputs.model) {
setChannels([]);
return;
}
let cancelled = false;
const loadChannels = async () => {
try {
const res = await API.get(
`/api/user/model_channels?model=${encodeURIComponent(inputs.model)}`
);
if (cancelled) return;
const { success, data } = res.data;
if (success && data) {
const channelList = data.channels || [];
setChannels(channelList);
const channelId = (data.default_channel_id && data.default_channel_id > 0)
? data.default_channel_id
: (channelList.length > 0 ? channelList[0].id : 0);
if (channelId !== inputs.channelId) {
handleInputChange('channelId', channelId);
}
}
} catch {
setChannels([]);
}
};
loadChannels();
return () => { cancelled = true; };
}, [inputs.model]);

// 消息编辑
const {
@@ -288,6 +323,9 @@ const Playground = () => {
inputs,
parameterEnabled,
);
if (inputs.channelId && inputs.channelId > 0) {
payload.channel_id = inputs.channelId;
}
sendRequest(payload, inputs.stream);

// 禁用图片模式
@@ -478,6 +516,7 @@ const Playground = () => {
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
channels={channels}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}


Caricamento…
Annulla
Salva