diff --git a/controller/relay.go b/controller/relay.go index 1d14dcc6..65fe6fbe 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -179,10 +179,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + RequestPath: c.Request.URL.Path, + Retry: common.GetPointer(0), } relayInfo.RetryIndex = 0 relayInfo.LastError = nil @@ -507,10 +508,11 @@ func RelayTask(c *gin.Context) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + RequestPath: c.Request.URL.Path, + Retry: common.GetPointer(0), } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { diff --git a/dto/channel_settings.go b/dto/channel_settings.go index bfe2ef8d..390853c9 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -73,8 +73,7 @@ const ( ) type AdvancedCustomConfig struct { - Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"` - Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"` + Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"` } type AdvancedCustomRoute struct { @@ -84,16 +83,63 @@ type AdvancedCustomRoute struct { Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"` } -type AdvancedCustomFallback struct { - Enabled bool `json:"enabled,omitempty"` -} - type AdvancedCustomRouteAuth struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` } +const advancedCustomModelPlaceholder = "{model}" + +// MatchPath returns the first route whose IncomingPath matches requestPath. +// Matching mirrors the relay adaptor: exact match, {model} placeholder, and +// :generateContent <-> :streamGenerateContent equivalence. +func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRoute, bool) { + if c == nil { + return AdvancedCustomRoute{}, false + } + for _, route := range c.Routes { + if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) { + return route, true + } + } + return AdvancedCustomRoute{}, false +} + +// SupportsPath reports whether any route matches requestPath. +func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool { + _, ok := c.MatchPath(requestPath) + return ok +} + +func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool { + if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) { + return true + } + if strings.Contains(configuredPath, ":generateContent") { + streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1) + return matchAdvancedCustomIncomingPathTemplate(streamPath, requestPath) + } + return false +} + +func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath string) bool { + if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) { + return configuredPath == requestPath + } + + parts := strings.Split(configuredPath, advancedCustomModelPlaceholder) + if len(parts) != 2 { + return false + } + if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) { + return false + } + + model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1]) + return model != "" && !strings.Contains(model, "/") +} + func IsAdvancedCustomConverterAllowed(converter string) bool { switch converter { case AdvancedCustomConverterNone, @@ -112,8 +158,8 @@ func (c *AdvancedCustomConfig) Validate() error { if c == nil { return fmt.Errorf("advanced_custom is required") } - if len(c.Routes) == 0 && !c.Fallback.Enabled { - return fmt.Errorf("advanced_custom requires at least one route or enabled fallback") + if len(c.Routes) == 0 { + return fmt.Errorf("advanced_custom requires at least one route") } seenPaths := make(map[string]struct{}, len(c.Routes)) diff --git a/middleware/distributor.go b/middleware/distributor.go index 258aebb5..cf5caa06 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -104,7 +104,8 @@ func Distribute() func(c *gin.Context) { if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { affinityUsable := false preferred, err := model.CacheGetChannel(preferredChannelID) - if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { + if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled && + channelSupportsRequestPath(preferred, c.Request.URL.Path) { if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroup(userGroup) @@ -132,10 +133,11 @@ func Distribute() func(c *gin.Context) { if channel == nil { channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ - Ctx: c, - ModelName: modelRequest.Model, - TokenGroup: usingGroup, - Retry: common.GetPointer(0), + Ctx: c, + ModelName: modelRequest.Model, + TokenGroup: usingGroup, + RequestPath: c.Request.URL.Path, + Retry: common.GetPointer(0), }) if err != nil { showGroup := usingGroup @@ -167,6 +169,20 @@ func Distribute() func(c *gin.Context) { } } +// channelSupportsRequestPath reports whether a channel can serve the request path. +// Only Advanced Custom (type 58) channels are path-checked; all other channel types +// always pass. A type-58 channel is usable only when one of its routes matches. +func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool { + if channel == nil { + return false + } + if channel.Type != constant.ChannelTypeAdvancedCustom { + return true + } + config := channel.GetOtherSettings().AdvancedCustom + return config != nil && config.SupportsPath(requestPath) +} + // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/model/ability.go b/model/ability.go index 1d7c53fa..61d72e03 100644 --- a/model/ability.go +++ b/model/ability.go @@ -7,6 +7,8 @@ import ( "sync" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/samber/lo" "gorm.io/gorm" @@ -103,7 +105,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { return channelQuery, nil } -func GetChannel(group string, model string, retry int) (*Channel, error) { +func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) { var abilities []Ability var err error = nil @@ -119,6 +121,7 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { if err != nil { return nil, err } + abilities = filterAbilitiesByRequestPath(abilities, requestPath) channel := Channel{} if len(abilities) > 0 { // Randomly choose one @@ -143,6 +146,52 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { return &channel, err } +// filterAbilitiesByRequestPath restricts candidates by request path for the DB +// (non-memory-cache) selection path. Only Advanced Custom (type 58) channels are +// path-checked: kept only when one of their routes matches requestPath; all other +// channel types always pass. When requestPath is empty, filtering is skipped. +func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Ability { + if requestPath == "" || len(abilities) == 0 { + return abilities + } + + channelIds := make([]int, 0, len(abilities)) + seen := make(map[int]struct{}, len(abilities)) + for _, ability := range abilities { + if _, ok := seen[ability.ChannelId]; ok { + continue + } + seen[ability.ChannelId] = struct{}{} + channelIds = append(channelIds, ability.ChannelId) + } + + var channels []*Channel + if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil { + // On error, fall back to unfiltered candidates to avoid blocking selection + return abilities + } + + advancedConfigs := make(map[int]*dto.AdvancedCustomConfig) + for _, channel := range channels { + if channel.Type == constant.ChannelTypeAdvancedCustom { + advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom + } + } + + filtered := make([]Ability, 0, len(abilities)) + for _, ability := range abilities { + config, isAdvancedCustom := advancedConfigs[ability.ChannelId] + if !isAdvancedCustom { + filtered = append(filtered, ability) + continue + } + if config != nil && config.SupportsPath(requestPath) { + filtered = append(filtered, ability) + } + } + return filtered +} + func (channel *Channel) AddAbilities(tx *gorm.DB) error { models_ := strings.Split(channel.Models, ",") groups_ := strings.Split(channel.Group, ",") diff --git a/model/channel.go b/model/channel.go index d4e726d0..725b8975 100644 --- a/model/channel.go +++ b/model/channel.go @@ -956,9 +956,6 @@ func (channel *Channel) ValidateSettings() error { if channelOtherSettings.AdvancedCustom == nil { return fmt.Errorf("advanced_custom is required") } - if channelOtherSettings.AdvancedCustom.Fallback.Enabled && (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") { - return fmt.Errorf("base_url is required when advanced_custom advanced_fallback is enabled") - } } if channelOtherSettings.AdvancedCustom != nil { if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil { diff --git a/model/channel_cache.go b/model/channel_cache.go index 03740d2c..8ad5d141 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -11,12 +11,16 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/setting/ratio_setting" ) var group2model2channels map[string]map[string][]int // enabled channel var channelsIDM map[int]*Channel // all channels include disabled +// channel2advancedCustomConfig caches parsed Advanced Custom (type 58) configs so +// path-aware selection avoids re-parsing JSON per request. Refreshed on full sync. +var channel2advancedCustomConfig map[int]*dto.AdvancedCustomConfig var channelSyncLock sync.RWMutex func InitChannelCache() { @@ -24,10 +28,16 @@ func InitChannelCache() { return } newChannelId2channel := make(map[int]*Channel) + newChannel2advancedCustomConfig := make(map[int]*dto.AdvancedCustomConfig) var channels []*Channel DB.Find(&channels) for _, channel := range channels { newChannelId2channel[channel.Id] = channel + if channel.Type == constant.ChannelTypeAdvancedCustom { + if config := channel.GetOtherSettings().AdvancedCustom; config != nil { + newChannel2advancedCustomConfig[channel.Id] = config + } + } } var abilities []*Ability DB.Find(&abilities) @@ -82,6 +92,7 @@ func InitChannelCache() { } } channelsIDM = newChannelId2channel + channel2advancedCustomConfig = newChannel2advancedCustomConfig channelSyncLock.Unlock() common.SysLog("channels synced from database") } @@ -94,22 +105,22 @@ func SyncChannelCache(frequency int) { } } -func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { +func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry) + return GetChannel(group, model, retry, requestPath) } channelSyncLock.RLock() defer channelSyncLock.RUnlock() // First, try to find channels with the exact model name. - channels := group2model2channels[group][model] + channels := filterChannelsByRequestPath(group2model2channels[group][model], requestPath) // If no channels found, try to find channels with the normalized model name. if len(channels) == 0 { normalizedModel := ratio_setting.FormatMatchingModelName(model) - channels = group2model2channels[group][normalizedModel] + channels = filterChannelsByRequestPath(group2model2channels[group][normalizedModel], requestPath) } if len(channels) == 0 { @@ -191,6 +202,34 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, return nil, errors.New("channel not found") } +// filterChannelsByRequestPath restricts candidates by request path. Only Advanced +// Custom (type 58) channels are path-checked: they are kept only when one of their +// configured routes matches requestPath. All other channel types always pass. +// When requestPath is empty (non-relay callers) filtering is skipped. +// Caller must hold channelSyncLock (read lock). The cached slice is never mutated. +func filterChannelsByRequestPath(channels []int, requestPath string) []int { + if requestPath == "" || len(channels) == 0 { + return channels + } + filtered := make([]int, 0, len(channels)) + for _, channelId := range channels { + channel, ok := channelsIDM[channelId] + if !ok { + // keep it so the downstream consistency error is raised as before + filtered = append(filtered, channelId) + continue + } + if channel.Type != constant.ChannelTypeAdvancedCustom { + filtered = append(filtered, channelId) + continue + } + if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPath(requestPath) { + filtered = append(filtered, channelId) + } + } + return filtered +} + func CacheGetChannel(id int) (*Channel, error) { if !common.MemoryCacheEnabled { return GetChannelById(id, true) diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index c0edbf5d..187f82ae 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -32,7 +32,6 @@ type Adaptor struct { geminiAdaptor gemini.Adaptor resolved bool - fallback bool converted bool route dto.AdvancedCustomRoute converter string @@ -49,7 +48,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if err != nil { return nil, err } - if a.fallback || converter == dto.AdvancedCustomConverterNone { + if converter == dto.AdvancedCustomConverterNone { return a.convertOpenAICompatibleRequest(c, info, request) } @@ -73,9 +72,6 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn if err != nil { return nil, err } - if a.fallback { - return a.convertClaudeToOpenAICompatibleRequest(c, info, request) - } switch converter { case dto.AdvancedCustomConverterNone: @@ -92,9 +88,6 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn if err != nil { return nil, err } - if a.fallback { - return a.convertGeminiToOpenAICompatibleRequest(c, info, request) - } switch converter { case dto.AdvancedCustomConverterNone: @@ -159,11 +152,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if err := a.resolve(nil, info); err != nil { return "", err } - if a.fallback { - return a.withTemporaryChannelType(info, constant.ChannelTypeOpenAI, func() (string, error) { - return a.openaiAdaptor.GetRequestURL(info) - }) - } return a.routeURL(info) } @@ -171,13 +159,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info * if err := a.resolve(c, info); err != nil { return err } - if a.fallback { - old := info.ChannelType - info.ChannelType = constant.ChannelTypeOpenAI - err := a.openaiAdaptor.SetupRequestHeader(c, header, info) - info.ChannelType = old - return err - } channel.SetupApiRequestHeader(info, c, header) auth := a.route.Auth @@ -205,7 +186,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request if err := a.resolve(c, info); err != nil { return nil, err } - if !a.converted && (a.fallback || a.converter != dto.AdvancedCustomConverterNone) { + if !a.converted && a.converter != dto.AdvancedCustomConverterNone { return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body") } @@ -224,9 +205,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom if err := a.resolve(c, info); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } - if a.fallback { - return a.openaiAdaptor.DoResponse(c, resp, info) - } switch a.converter { case dto.AdvancedCustomConverterNone: @@ -295,9 +273,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error { } incomingPath := incomingRequestPath(c, info) - route, ok := lo.Find(config.Routes, func(route dto.AdvancedCustomRoute) bool { - return matchIncomingPath(strings.TrimSpace(route.IncomingPath), incomingPath) - }) + route, ok := config.MatchPath(incomingPath) if ok { route.Converter = strings.TrimSpace(route.Converter) if route.Converter == "" { @@ -308,13 +284,7 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error { a.resolved = true return nil } - if config.Fallback.Enabled { - a.fallback = true - a.converter = dto.AdvancedCustomConverterNone - a.resolved = true - return nil - } - return fmt.Errorf("advanced custom route not found for path: %s", incomingPath) + return fmt.Errorf("advanced custom channel does not support request path: %s", incomingPath) } func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string { @@ -327,34 +297,6 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string { return strings.Split(info.RequestURLPath, "?")[0] } -func matchIncomingPath(configuredPath string, requestPath string) bool { - if matchIncomingPathTemplate(configuredPath, requestPath) { - return true - } - if strings.Contains(configuredPath, ":generateContent") { - streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1) - return matchIncomingPathTemplate(streamPath, requestPath) - } - return false -} - -func matchIncomingPathTemplate(configuredPath string, requestPath string) bool { - if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) { - return configuredPath == requestPath - } - - parts := strings.Split(configuredPath, advancedCustomModelPlaceholder) - if len(parts) != 2 { - return false - } - if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) { - return false - } - - model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1]) - return model != "" && !strings.Contains(model, "/") -} - func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) { parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info) if err != nil { @@ -535,11 +477,3 @@ func (a *Adaptor) convertOpenAICompatibleImageRequest(c *gin.Context, info *rela info.ChannelType = old return converted, err } - -func (a *Adaptor) withTemporaryChannelType(info *relaycommon.RelayInfo, channelType int, fn func() (string, error)) (string, error) { - old := info.ChannelType - info.ChannelType = channelType - value, err := fn() - info.ChannelType = old - return value, err -} diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index f56cf513..c8e8d2e3 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -161,7 +161,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) { assert.Equal(t, "2023-06-01", header.Get("anthropic-version")) } -func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) { +func TestAdaptorReturnsErrorWhenNoRouteMatchesPath(t *testing.T) { adaptor := &Adaptor{} info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ Routes: []dto.AdvancedCustomRoute{ @@ -176,20 +176,7 @@ func TestAdaptorReturnsErrorWhenNoRouteAndFallbackDisabled(t *testing.T) { _, err := adaptor.GetRequestURL(info) require.Error(t, err) - assert.Contains(t, err.Error(), "route not found") -} - -func TestAdaptorFallbackUsesOpenAICompatibleBaseURL(t *testing.T) { - adaptor := &Adaptor{} - info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ - Fallback: dto.AdvancedCustomFallback{Enabled: true}, - }) - info.RequestURLPath = "/v1/messages" - info.RelayFormat = types.RelayFormatClaude - - requestURL, err := adaptor.GetRequestURL(info) - require.NoError(t, err) - assert.Equal(t, "https://fallback.example/v1/chat/completions", requestURL) + assert.Contains(t, err.Error(), "does not support request path") } func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) { diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8..24c4e252 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -15,6 +15,7 @@ type RetryParam struct { Ctx *gin.Context TokenGroup string ModelName string + RequestPath string Retry *int resetNextTry bool } @@ -115,7 +116,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -153,7 +154,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) + channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath) if err != nil { return nil, param.TokenGroup, err } diff --git a/web/default/src/components/multi-select.tsx b/web/default/src/components/multi-select.tsx index d1f9f178..649df984 100644 --- a/web/default/src/components/multi-select.tsx +++ b/web/default/src/components/multi-select.tsx @@ -20,6 +20,8 @@ import * as React from 'react' import { Add01Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { copyToClipboard } from '@/lib/copy-to-clipboard' import { cn } from '@/lib/utils' import { Combobox, @@ -64,6 +66,11 @@ interface MultiSelectProps { * Hidden values remain searchable/removable from the dropdown. */ maxVisibleChips?: number + /** + * When true, clicking a chip's label copies its value to the clipboard + * instead of being inert. The remove (×) button keeps its own behaviour. + */ + copyChipOnClick?: boolean } const COMMA_REGEX = /[,,\n]/ @@ -109,6 +116,7 @@ export function MultiSelect(props: MultiSelectProps) { const [inputValue, setInputValue] = React.useState('') const [open, setOpen] = React.useState(false) + const [expanded, setExpanded] = React.useState(false) const selectedSet = React.useMemo( () => new Set(props.selected), @@ -195,6 +203,25 @@ export function MultiSelect(props: MultiSelectProps) { } } + const handleCopyChip = React.useCallback( + async ( + event: React.MouseEvent, + value: string, + label: string + ) => { + // Prevent the click from toggling the combobox popup or focusing input. + event.preventDefault() + event.stopPropagation() + const ok = await copyToClipboard(value) + if (ok) { + toast.success(t('Copied: {{model}}', { model: label })) + } else { + toast.error(t('Failed to copy')) + } + }, + [t] + ) + const handleKeyDown = (event: React.KeyboardEvent) => { // Enter without a highlighted option commits the typed value. if (event.key === 'Enter' && props.allowCreate && canCreate) { @@ -231,26 +258,69 @@ export function MultiSelect(props: MultiSelectProps) { > {(values: string[]) => { - const visibleValues = - typeof props.maxVisibleChips === 'number' - ? values.slice(0, props.maxVisibleChips) - : values + const shouldLimit = + typeof props.maxVisibleChips === 'number' && !expanded + const visibleValues = shouldLimit + ? values.slice(0, props.maxVisibleChips) + : values const hiddenCount = values.length - visibleValues.length return ( <> - {visibleValues.map((value) => ( - - - {labelMap.get(value) ?? value} - - - ))} + {visibleValues.map((value) => { + const label = labelMap.get(value) ?? value + return ( + + {props.copyChipOnClick ? ( + + ) : ( + {label} + )} + + ) + })} {hiddenCount > 0 && ( - + )} + {expanded && + typeof props.maxVisibleChips === 'number' && + values.length > props.maxVisibleChips && ( + + )} ) }} diff --git a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx index 2b2ab384..90ea076e 100644 --- a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx @@ -33,7 +33,6 @@ import { SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' -import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { Dialog } from '@/components/dialog' import { @@ -152,13 +151,6 @@ export function AdvancedCustomEditorDialog({ }) } - const setFallbackEnabled = (enabled: boolean) => { - setConfig((current) => ({ - ...normalizeAdvancedCustomConfig(current), - advanced_fallback: { enabled }, - })) - } - const parseJsonEditorConfig = (): AdvancedCustomConfig | null => { const parsed = parseAdvancedCustomConfig(jsonText) if (!parsed) { @@ -215,11 +207,6 @@ export function AdvancedCustomEditorDialog({ ...(base.advanced_routes || []), ...(template.advanced_routes || []), ], - advanced_fallback: { - enabled: - base.advanced_fallback?.enabled === true || - template.advanced_fallback?.enabled === true, - }, } } @@ -355,23 +342,7 @@ export function AdvancedCustomEditorDialog({ {editMode === 'visual' ? (
-
-
- -
-
- {t('Fallback routing')} -
-
- {t( - 'When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.' - )} -
-
-
+