- 新增 /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 { | |||
| 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 != "" { | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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) | |||
| ); | |||
| }, | |||
| ); | |||
| @@ -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 | |||
| @@ -85,6 +85,7 @@ export const DEFAULT_CONFIG = { | |||
| inputs: { | |||
| model: 'gpt-4o', | |||
| group: '', | |||
| channelId: 0, | |||
| temperature: 0.7, | |||
| top_p: 1, | |||
| max_tokens: 4096, | |||
| @@ -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 { | |||
| @@ -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, | |||
| @@ -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} | |||