- 新增 /api/user/model_channels 接口,返回模型可用渠道及默认渠道 - Playground 设置面板添加渠道选择下拉框 - Distribute 中间件支持从请求体读取 channel_id 指定渠道 - 支持 URL 参数 ?model=xxx 直接选择模型 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -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, | |||||
| }, | |||||
| }) | |||||
| } | |||||
| @@ -23,19 +23,20 @@ import ( | |||||
| ) | ) | ||||
| type ModelRequest struct { | 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) { | func Distribute() func(c *gin.Context) { | ||||
| return func(c *gin.Context) { | return func(c *gin.Context) { | ||||
| var channel *model.Channel | var channel *model.Channel | ||||
| channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) | |||||
| modelRequest, shouldSelectChannel, err := getModelRequest(c) | modelRequest, shouldSelectChannel, err := getModelRequest(c) | ||||
| if err != nil { | if err != nil { | ||||
| abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) | abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) | ||||
| return | return | ||||
| } | } | ||||
| channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) | |||||
| if ok { | if ok { | ||||
| id, err := strconv.Atoi(channelId.(string)) | id, err := strconv.Atoi(channelId.(string)) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -368,6 +369,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { | |||||
| modelRequest.Model = req.Model | modelRequest.Model = req.Model | ||||
| modelRequest.Group = req.Group | modelRequest.Group = req.Group | ||||
| common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.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 != "" { | if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" { | ||||
| @@ -66,6 +66,57 @@ func GetAbilitiesByChannelId(channelId int) ([]*Ability, error) { | |||||
| return abilities, err | 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) { | func getPriority(group string, model string, retry int) (int, error) { | ||||
| var priorities []int | var priorities []int | ||||
| @@ -75,6 +75,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| selfRoute.GET("/self/groups", controller.GetUserGroups) | selfRoute.GET("/self/groups", controller.GetUserGroups) | ||||
| selfRoute.GET("/self", controller.GetSelf) | selfRoute.GET("/self", controller.GetSelf) | ||||
| selfRoute.GET("/models", controller.GetUserModels) | selfRoute.GET("/models", controller.GetUserModels) | ||||
| selfRoute.GET("/model_channels", controller.GetModelChannels) | |||||
| selfRoute.GET("/channels", controller.GetUserChannelsForBinding) | selfRoute.GET("/channels", controller.GetUserChannelsForBinding) | ||||
| selfRoute.PUT("/self", controller.UpdateSelf) | selfRoute.PUT("/self", controller.UpdateSelf) | ||||
| selfRoute.DELETE("/self", controller.DeleteSelf) | selfRoute.DELETE("/self", controller.DeleteSelf) | ||||
| @@ -74,7 +74,8 @@ export const OptimizedSettingsPanel = React.memo( | |||||
| prevProps.showSettings === nextProps.showSettings && | prevProps.showSettings === nextProps.showSettings && | ||||
| 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) | |||||
| ); | ); | ||||
| }, | }, | ||||
| ); | ); | ||||
| @@ -45,6 +45,7 @@ const SettingsPanel = ({ | |||||
| onCustomRequestBodyChange, | onCustomRequestBodyChange, | ||||
| previewPayload, | previewPayload, | ||||
| messages, | messages, | ||||
| channels = [], | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -176,6 +177,38 @@ const SettingsPanel = ({ | |||||
| /> | /> | ||||
| </div> | </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输入 */} | {/* 图片URL输入 */} | ||||
| <div className={customRequestMode ? 'opacity-50' : ''}> | <div className={customRequestMode ? 'opacity-50' : ''}> | ||||
| <ImageUrlInput | <ImageUrlInput | ||||
| @@ -85,6 +85,7 @@ export const DEFAULT_CONFIG = { | |||||
| inputs: { | inputs: { | ||||
| model: 'gpt-4o', | model: 'gpt-4o', | ||||
| group: '', | group: '', | ||||
| channelId: 0, | |||||
| temperature: 0.7, | temperature: 0.7, | ||||
| top_p: 1, | top_p: 1, | ||||
| max_tokens: 4096, | max_tokens: 4096, | ||||
| @@ -28,6 +28,7 @@ export const useDataLoader = ( | |||||
| handleInputChange, | handleInputChange, | ||||
| setModels, | setModels, | ||||
| setGroups, | setGroups, | ||||
| searchParams, | |||||
| ) => { | ) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -37,10 +38,15 @@ export const useDataLoader = ( | |||||
| const { success, message, data } = res.data; | const { success, message, data } = res.data; | ||||
| if (success) { | 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); | setModels(modelOptions); | ||||
| if (selectedModel !== inputs.model) { | if (selectedModel !== inputs.model) { | ||||
| @@ -52,7 +58,7 @@ export const useDataLoader = ( | |||||
| } catch (error) { | } catch (error) { | ||||
| showError(t('加载模型失败')); | showError(t('加载模型失败')); | ||||
| } | } | ||||
| }, [inputs.model, handleInputChange, setModels, t]); | |||||
| }, [inputs.model, handleInputChange, setModels, t, searchParams]); | |||||
| const loadGroups = useCallback(async () => { | const loadGroups = useCallback(async () => { | ||||
| try { | try { | ||||
| @@ -83,6 +83,7 @@ export const usePlaygroundState = () => { | |||||
| const [showSettings, setShowSettings] = useState(false); | const [showSettings, setShowSettings] = useState(false); | ||||
| const [models, setModels] = useState([]); | const [models, setModels] = useState([]); | ||||
| const [groups, setGroups] = useState([]); | const [groups, setGroups] = useState([]); | ||||
| const [channels, setChannels] = useState([]); | |||||
| const [status, setStatus] = useState({}); | const [status, setStatus] = useState({}); | ||||
| // 消息相关状态 - 使用加载的消息或默认消息初始化 | // 消息相关状态 - 使用加载的消息或默认消息初始化 | ||||
| @@ -287,6 +288,8 @@ export const usePlaygroundState = () => { | |||||
| setShowSettings, | setShowSettings, | ||||
| setModels, | setModels, | ||||
| setGroups, | setGroups, | ||||
| channels, | |||||
| setChannels, | |||||
| setStatus, | setStatus, | ||||
| setMessage, | setMessage, | ||||
| setDebugData, | setDebugData, | ||||
| @@ -48,6 +48,7 @@ import { | |||||
| getTextContent, | getTextContent, | ||||
| buildApiPayload, | buildApiPayload, | ||||
| encodeToBase64, | encodeToBase64, | ||||
| API, | |||||
| } from '../../helpers'; | } from '../../helpers'; | ||||
| // Components | // Components | ||||
| @@ -110,6 +111,8 @@ const Playground = () => { | |||||
| setShowSettings, | setShowSettings, | ||||
| setModels, | setModels, | ||||
| setGroups, | setGroups, | ||||
| channels, | |||||
| setChannels, | |||||
| setStatus, | setStatus, | ||||
| setMessage, | setMessage, | ||||
| setDebugData, | 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 { | const { | ||||
| @@ -288,6 +323,9 @@ const Playground = () => { | |||||
| inputs, | inputs, | ||||
| parameterEnabled, | parameterEnabled, | ||||
| ); | ); | ||||
| if (inputs.channelId && inputs.channelId > 0) { | |||||
| payload.channel_id = inputs.channelId; | |||||
| } | |||||
| sendRequest(payload, inputs.stream); | sendRequest(payload, inputs.stream); | ||||
| // 禁用图片模式 | // 禁用图片模式 | ||||
| @@ -478,6 +516,7 @@ const Playground = () => { | |||||
| parameterEnabled={parameterEnabled} | parameterEnabled={parameterEnabled} | ||||
| models={models} | models={models} | ||||
| groups={groups} | groups={groups} | ||||
| channels={channels} | |||||
| styleState={styleState} | styleState={styleState} | ||||
| showSettings={showSettings} | showSettings={showSettings} | ||||
| showDebugPanel={showDebugPanel} | showDebugPanel={showDebugPanel} | ||||