mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 11:28:38 +00:00
feat:add CLI param-override templates with visual editor and apply on first rule match
This commit is contained in:
@@ -348,8 +348,13 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
|
|||||||
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
|
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
|
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
|
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
|
paramOverride := channel.GetParamOverride()
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride())
|
headerOverride := channel.GetHeaderOverride()
|
||||||
|
if mergedParam, applied := service.ApplyChannelAffinityOverrideTemplate(c, paramOverride); applied {
|
||||||
|
paramOverride = mergedParam
|
||||||
|
}
|
||||||
|
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, paramOverride)
|
||||||
|
common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, headerOverride)
|
||||||
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
|
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
|
||||||
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
|
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type channelAffinityMeta struct {
|
|||||||
TTLSeconds int
|
TTLSeconds int
|
||||||
RuleName string
|
RuleName string
|
||||||
SkipRetry bool
|
SkipRetry bool
|
||||||
|
ParamTemplate map[string]interface{}
|
||||||
KeySourceType string
|
KeySourceType string
|
||||||
KeySourceKey string
|
KeySourceKey string
|
||||||
KeySourcePath string
|
KeySourcePath string
|
||||||
@@ -415,6 +416,84 @@ func buildChannelAffinityKeyHint(s string) string {
|
|||||||
return s[:4] + "..." + s[len(s)-4:]
|
return s[:4] + "..." + s[len(s)-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneStringAnyMap(src map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
dst := make(map[string]interface{}, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeChannelOverride(base map[string]interface{}, tpl map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(base) == 0 && len(tpl) == 0 {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if len(tpl) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
out := cloneStringAnyMap(base)
|
||||||
|
for k, v := range tpl {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendChannelAffinityTemplateAdminInfo(c *gin.Context, meta channelAffinityMeta) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(meta.ParamTemplate) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templateInfo := map[string]interface{}{
|
||||||
|
"applied": true,
|
||||||
|
"rule_name": meta.RuleName,
|
||||||
|
"param_override_keys": len(meta.ParamTemplate),
|
||||||
|
}
|
||||||
|
if anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo); ok {
|
||||||
|
if info, ok := anyInfo.(map[string]interface{}); ok {
|
||||||
|
info["override_template"] = templateInfo
|
||||||
|
c.Set(ginKeyChannelAffinityLogInfo, info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set(ginKeyChannelAffinityLogInfo, map[string]interface{}{
|
||||||
|
"reason": meta.RuleName,
|
||||||
|
"rule_name": meta.RuleName,
|
||||||
|
"using_group": meta.UsingGroup,
|
||||||
|
"model": meta.ModelName,
|
||||||
|
"request_path": meta.RequestPath,
|
||||||
|
"key_source": meta.KeySourceType,
|
||||||
|
"key_key": meta.KeySourceKey,
|
||||||
|
"key_path": meta.KeySourcePath,
|
||||||
|
"key_hint": meta.KeyHint,
|
||||||
|
"key_fp": meta.KeyFingerprint,
|
||||||
|
"override_template": templateInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyChannelAffinityOverrideTemplate merges per-rule channel override templates onto the selected channel override config.
|
||||||
|
func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[string]interface{}) (map[string]interface{}, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return paramOverride, false
|
||||||
|
}
|
||||||
|
meta, ok := getChannelAffinityMeta(c)
|
||||||
|
if !ok {
|
||||||
|
return paramOverride, false
|
||||||
|
}
|
||||||
|
if len(meta.ParamTemplate) == 0 {
|
||||||
|
return paramOverride, false
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedParam := mergeChannelOverride(paramOverride, meta.ParamTemplate)
|
||||||
|
appendChannelAffinityTemplateAdminInfo(c, meta)
|
||||||
|
return mergedParam, true
|
||||||
|
}
|
||||||
|
|
||||||
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
|
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
|
||||||
setting := operation_setting.GetChannelAffinitySetting()
|
setting := operation_setting.GetChannelAffinitySetting()
|
||||||
if setting == nil || !setting.Enabled {
|
if setting == nil || !setting.Enabled {
|
||||||
@@ -466,6 +545,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
|||||||
TTLSeconds: ttlSeconds,
|
TTLSeconds: ttlSeconds,
|
||||||
RuleName: rule.Name,
|
RuleName: rule.Name,
|
||||||
SkipRetry: rule.SkipRetryOnFailure,
|
SkipRetry: rule.SkipRetryOnFailure,
|
||||||
|
ParamTemplate: cloneStringAnyMap(rule.ParamOverrideTemplate),
|
||||||
KeySourceType: strings.TrimSpace(usedSource.Type),
|
KeySourceType: strings.TrimSpace(usedSource.Type),
|
||||||
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
||||||
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
||||||
|
|||||||
69
service/channel_affinity_template_test.go
Normal file
69
service/channel_affinity_template_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildChannelAffinityTemplateContextForTest(meta channelAffinityMeta) *gin.Context {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
setChannelAffinityContext(ctx, meta)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyChannelAffinityOverrideTemplate_NoTemplate(t *testing.T) {
|
||||||
|
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||||
|
RuleName: "rule-no-template",
|
||||||
|
})
|
||||||
|
base := map[string]interface{}{
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
|
||||||
|
require.False(t, applied)
|
||||||
|
require.Equal(t, base, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyChannelAffinityOverrideTemplate_MergeTemplate(t *testing.T) {
|
||||||
|
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
|
||||||
|
RuleName: "rule-with-template",
|
||||||
|
ParamTemplate: map[string]interface{}{
|
||||||
|
"temperature": 0.2,
|
||||||
|
"top_p": 0.95,
|
||||||
|
},
|
||||||
|
UsingGroup: "default",
|
||||||
|
ModelName: "gpt-4.1",
|
||||||
|
RequestPath: "/v1/responses",
|
||||||
|
KeySourceType: "gjson",
|
||||||
|
KeySourcePath: "prompt_cache_key",
|
||||||
|
KeyHint: "abcd...wxyz",
|
||||||
|
KeyFingerprint: "abcd1234",
|
||||||
|
})
|
||||||
|
base := map[string]interface{}{
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
|
||||||
|
require.True(t, applied)
|
||||||
|
require.Equal(t, 0.2, merged["temperature"])
|
||||||
|
require.Equal(t, 0.95, merged["top_p"])
|
||||||
|
require.Equal(t, 2000, merged["max_tokens"])
|
||||||
|
require.Equal(t, 0.7, base["temperature"])
|
||||||
|
|
||||||
|
anyInfo, ok := ctx.Get(ginKeyChannelAffinityLogInfo)
|
||||||
|
require.True(t, ok)
|
||||||
|
info, ok := anyInfo.(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
overrideInfoAny, ok := info["override_template"]
|
||||||
|
require.True(t, ok)
|
||||||
|
overrideInfo, ok := overrideInfoAny.(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, true, overrideInfo["applied"])
|
||||||
|
require.Equal(t, "rule-with-template", overrideInfo["rule_name"])
|
||||||
|
require.EqualValues(t, 2, overrideInfo["param_override_keys"])
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
|
|||||||
ValueRegex string `json:"value_regex"`
|
ValueRegex string `json:"value_regex"`
|
||||||
TTLSeconds int `json:"ttl_seconds"`
|
TTLSeconds int `json:"ttl_seconds"`
|
||||||
|
|
||||||
|
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
|
||||||
|
|
||||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
||||||
|
|
||||||
IncludeUsingGroup bool `json:"include_using_group"`
|
IncludeUsingGroup bool `json:"include_using_group"`
|
||||||
@@ -32,6 +34,44 @@ type ChannelAffinitySetting struct {
|
|||||||
Rules []ChannelAffinityRule `json:"rules"`
|
Rules []ChannelAffinityRule `json:"rules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var codexCliPassThroughHeaders = []string{
|
||||||
|
"Originator",
|
||||||
|
"Session_id",
|
||||||
|
"User-Agent",
|
||||||
|
"X-Codex-Beta-Features",
|
||||||
|
"X-Codex-Turn-Metadata",
|
||||||
|
}
|
||||||
|
|
||||||
|
var claudeCliPassThroughHeaders = []string{
|
||||||
|
"X-Stainless-Arch",
|
||||||
|
"X-Stainless-Lang",
|
||||||
|
"X-Stainless-Os",
|
||||||
|
"X-Stainless-Package-Version",
|
||||||
|
"X-Stainless-Retry-Count",
|
||||||
|
"X-Stainless-Runtime",
|
||||||
|
"X-Stainless-Runtime-Version",
|
||||||
|
"X-Stainless-Timeout",
|
||||||
|
"User-Agent",
|
||||||
|
"X-App",
|
||||||
|
"Anthropic-Beta",
|
||||||
|
"Anthropic-Dangerous-Direct-Browser-Access",
|
||||||
|
"Anthropic-Version",
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPassHeaderTemplate(headers []string) map[string]interface{} {
|
||||||
|
clonedHeaders := make([]string, 0, len(headers))
|
||||||
|
clonedHeaders = append(clonedHeaders, headers...)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"operations": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"mode": "pass_headers",
|
||||||
|
"value": clonedHeaders,
|
||||||
|
"keep_origin": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var channelAffinitySetting = ChannelAffinitySetting{
|
var channelAffinitySetting = ChannelAffinitySetting{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
SwitchOnSuccess: true,
|
SwitchOnSuccess: true,
|
||||||
@@ -39,32 +79,34 @@ var channelAffinitySetting = ChannelAffinitySetting{
|
|||||||
DefaultTTLSeconds: 3600,
|
DefaultTTLSeconds: 3600,
|
||||||
Rules: []ChannelAffinityRule{
|
Rules: []ChannelAffinityRule{
|
||||||
{
|
{
|
||||||
Name: "codex trace",
|
Name: "codex cli trace",
|
||||||
ModelRegex: []string{"^gpt-.*$"},
|
ModelRegex: []string{"^gpt-.*$"},
|
||||||
PathRegex: []string{"/v1/responses"},
|
PathRegex: []string{"/v1/responses"},
|
||||||
KeySources: []ChannelAffinityKeySource{
|
KeySources: []ChannelAffinityKeySource{
|
||||||
{Type: "gjson", Path: "prompt_cache_key"},
|
{Type: "gjson", Path: "prompt_cache_key"},
|
||||||
},
|
},
|
||||||
ValueRegex: "",
|
ValueRegex: "",
|
||||||
TTLSeconds: 0,
|
TTLSeconds: 0,
|
||||||
SkipRetryOnFailure: false,
|
ParamOverrideTemplate: buildPassHeaderTemplate(codexCliPassThroughHeaders),
|
||||||
IncludeUsingGroup: true,
|
SkipRetryOnFailure: false,
|
||||||
IncludeRuleName: true,
|
IncludeUsingGroup: true,
|
||||||
UserAgentInclude: nil,
|
IncludeRuleName: true,
|
||||||
|
UserAgentInclude: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "claude code trace",
|
Name: "claude cli trace",
|
||||||
ModelRegex: []string{"^claude-.*$"},
|
ModelRegex: []string{"^claude-.*$"},
|
||||||
PathRegex: []string{"/v1/messages"},
|
PathRegex: []string{"/v1/messages"},
|
||||||
KeySources: []ChannelAffinityKeySource{
|
KeySources: []ChannelAffinityKeySource{
|
||||||
{Type: "gjson", Path: "metadata.user_id"},
|
{Type: "gjson", Path: "metadata.user_id"},
|
||||||
},
|
},
|
||||||
ValueRegex: "",
|
ValueRegex: "",
|
||||||
TTLSeconds: 0,
|
TTLSeconds: 0,
|
||||||
SkipRetryOnFailure: false,
|
ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders),
|
||||||
IncludeUsingGroup: true,
|
SkipRetryOnFailure: false,
|
||||||
IncludeRuleName: true,
|
IncludeUsingGroup: true,
|
||||||
UserAgentInclude: nil,
|
IncludeRuleName: true,
|
||||||
|
UserAgentInclude: nil,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import {
|
|||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||||
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
|
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
|
||||||
|
import {
|
||||||
|
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
} from '../../../../constants/channel-affinity-template.constants';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -329,6 +333,18 @@ const TEMPLATE_PRESET_CONFIG = {
|
|||||||
kind: 'operations',
|
kind: 'operations',
|
||||||
payload: GEMINI_IMAGE_4K_TEMPLATE,
|
payload: GEMINI_IMAGE_4K_TEMPLATE,
|
||||||
},
|
},
|
||||||
|
claude_cli_headers_passthrough: {
|
||||||
|
group: 'scenario',
|
||||||
|
label: 'Claude CLI 请求头透传',
|
||||||
|
kind: 'operations',
|
||||||
|
payload: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
},
|
||||||
|
codex_cli_headers_passthrough: {
|
||||||
|
group: 'scenario',
|
||||||
|
label: 'Codex CLI 请求头透传',
|
||||||
|
kind: 'operations',
|
||||||
|
payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_GUIDE_TARGET_OPTIONS = [
|
const FIELD_GUIDE_TARGET_OPTIONS = [
|
||||||
|
|||||||
90
web/src/constants/channel-affinity-template.constants.js
Normal file
90
web/src/constants/channel-affinity-template.constants.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
const buildPassHeadersTemplate = (headers) => ({
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
mode: 'pass_headers',
|
||||||
|
value: [...headers],
|
||||||
|
keep_origin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CODEX_CLI_HEADER_PASSTHROUGH_HEADERS = [
|
||||||
|
'Originator',
|
||||||
|
'Session_id',
|
||||||
|
'User-Agent',
|
||||||
|
'X-Codex-Beta-Features',
|
||||||
|
'X-Codex-Turn-Metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [
|
||||||
|
'X-Stainless-Arch',
|
||||||
|
'X-Stainless-Lang',
|
||||||
|
'X-Stainless-Os',
|
||||||
|
'X-Stainless-Package-Version',
|
||||||
|
'X-Stainless-Retry-Count',
|
||||||
|
'X-Stainless-Runtime',
|
||||||
|
'X-Stainless-Runtime-Version',
|
||||||
|
'X-Stainless-Timeout',
|
||||||
|
'User-Agent',
|
||||||
|
'X-App',
|
||||||
|
'Anthropic-Beta',
|
||||||
|
'Anthropic-Dangerous-Direct-Browser-Access',
|
||||||
|
'Anthropic-Version',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
|
||||||
|
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
|
||||||
|
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
|
||||||
|
codexCli: {
|
||||||
|
name: 'codex cli trace',
|
||||||
|
model_regex: ['^gpt-.*$'],
|
||||||
|
path_regex: ['/v1/responses'],
|
||||||
|
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
|
||||||
|
param_override_template: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
value_regex: '',
|
||||||
|
ttl_seconds: 0,
|
||||||
|
skip_retry_on_failure: false,
|
||||||
|
include_using_group: true,
|
||||||
|
include_rule_name: true,
|
||||||
|
},
|
||||||
|
claudeCli: {
|
||||||
|
name: 'claude cli trace',
|
||||||
|
model_regex: ['^claude-.*$'],
|
||||||
|
path_regex: ['/v1/messages'],
|
||||||
|
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
|
||||||
|
param_override_template: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||||
|
value_regex: '',
|
||||||
|
ttl_seconds: 0,
|
||||||
|
skip_retry_on_failure: false,
|
||||||
|
include_using_group: true,
|
||||||
|
include_rule_name: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cloneChannelAffinityTemplate = (template) =>
|
||||||
|
JSON.parse(JSON.stringify(template || {}));
|
||||||
@@ -24,3 +24,4 @@ export * from './common.constant';
|
|||||||
export * from './dashboard.constants';
|
export * from './dashboard.constants';
|
||||||
export * from './playground.constants';
|
export * from './playground.constants';
|
||||||
export * from './redemption.constants';
|
export * from './redemption.constants';
|
||||||
|
export * from './channel-affinity-template.constants';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
@@ -37,10 +37,12 @@ import {
|
|||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconClose,
|
IconClose,
|
||||||
|
IconCode,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
|
IconSearch,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import {
|
import {
|
||||||
API,
|
API,
|
||||||
@@ -52,6 +54,11 @@ import {
|
|||||||
verifyJSON,
|
verifyJSON,
|
||||||
} from '../../../helpers';
|
} from '../../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
CHANNEL_AFFINITY_RULE_TEMPLATES,
|
||||||
|
cloneChannelAffinityTemplate,
|
||||||
|
} from '../../../constants/channel-affinity-template.constants';
|
||||||
|
import ParamOverrideEditorModal from '../../../components/table/channels/modals/ParamOverrideEditorModal';
|
||||||
|
|
||||||
const KEY_ENABLED = 'channel_affinity_setting.enabled';
|
const KEY_ENABLED = 'channel_affinity_setting.enabled';
|
||||||
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
|
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
|
||||||
@@ -65,31 +72,6 @@ const KEY_SOURCE_TYPES = [
|
|||||||
{ label: 'gjson', value: 'gjson' },
|
{ label: 'gjson', value: 'gjson' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_TEMPLATES = {
|
|
||||||
codex: {
|
|
||||||
name: 'codex trace',
|
|
||||||
model_regex: ['^gpt-.*$'],
|
|
||||||
path_regex: ['/v1/responses'],
|
|
||||||
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
|
|
||||||
value_regex: '',
|
|
||||||
ttl_seconds: 0,
|
|
||||||
skip_retry_on_failure: false,
|
|
||||||
include_using_group: true,
|
|
||||||
include_rule_name: true,
|
|
||||||
},
|
|
||||||
claudeCode: {
|
|
||||||
name: 'claude-code trace',
|
|
||||||
model_regex: ['^claude-.*$'],
|
|
||||||
path_regex: ['/v1/messages'],
|
|
||||||
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
|
|
||||||
value_regex: '',
|
|
||||||
ttl_seconds: 0,
|
|
||||||
skip_retry_on_failure: false,
|
|
||||||
include_using_group: true,
|
|
||||||
include_rule_name: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTEXT_KEY_PRESETS = [
|
const CONTEXT_KEY_PRESETS = [
|
||||||
{ key: 'id', label: 'id(用户 ID)' },
|
{ key: 'id', label: 'id(用户 ID)' },
|
||||||
{ key: 'token_id', label: 'token_id' },
|
{ key: 'token_id', label: 'token_id' },
|
||||||
@@ -114,6 +96,11 @@ const RULES_JSON_PLACEHOLDER = `[
|
|||||||
],
|
],
|
||||||
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
|
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
|
||||||
"ttl_seconds": 600,
|
"ttl_seconds": 600,
|
||||||
|
"param_override_template": {
|
||||||
|
"operations": [
|
||||||
|
{ "path": "temperature", "mode": "set", "value": 0.2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
"skip_retry_on_failure": false,
|
"skip_retry_on_failure": false,
|
||||||
"include_using_group": true,
|
"include_using_group": true,
|
||||||
"include_rule_name": true
|
"include_rule_name": true
|
||||||
@@ -187,6 +174,23 @@ const tryParseRulesJsonArray = (jsonString) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseOptionalObjectJson = (jsonString, label) => {
|
||||||
|
const raw = (jsonString || '').trim();
|
||||||
|
if (!raw) return { ok: true, value: null };
|
||||||
|
if (!verifyJSON(raw)) {
|
||||||
|
return { ok: false, message: `${label} JSON 格式不正确` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return { ok: false, message: `${label} 必须是 JSON 对象` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: `${label} JSON 格式不正确` };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function SettingsChannelAffinity(props) {
|
export default function SettingsChannelAffinity(props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -222,6 +226,9 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
const [modalInitValues, setModalInitValues] = useState(null);
|
const [modalInitValues, setModalInitValues] = useState(null);
|
||||||
const [modalFormKey, setModalFormKey] = useState(0);
|
const [modalFormKey, setModalFormKey] = useState(0);
|
||||||
const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
|
const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
|
||||||
|
const [paramTemplateDraft, setParamTemplateDraft] = useState('');
|
||||||
|
const [paramTemplateEditorVisible, setParamTemplateEditorVisible] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const effectiveDefaultTTLSeconds =
|
const effectiveDefaultTTLSeconds =
|
||||||
Number(inputs?.[KEY_DEFAULT_TTL] || 0) > 0
|
Number(inputs?.[KEY_DEFAULT_TTL] || 0) > 0
|
||||||
@@ -240,9 +247,99 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
||||||
include_using_group: r.include_using_group ?? true,
|
include_using_group: r.include_using_group ?? true,
|
||||||
include_rule_name: r.include_rule_name ?? true,
|
include_rule_name: r.include_rule_name ?? true,
|
||||||
|
param_override_template_json: r.param_override_template
|
||||||
|
? stringifyPretty(r.param_override_template)
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paramTemplatePreviewMeta = useMemo(() => {
|
||||||
|
const raw = (paramTemplateDraft || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('未设置'),
|
||||||
|
tagColor: 'grey',
|
||||||
|
preview: t('当前规则未设置参数覆盖模板'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!verifyJSON(raw)) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('JSON 无效'),
|
||||||
|
tagColor: 'red',
|
||||||
|
preview: raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
tagLabel: t('已设置'),
|
||||||
|
tagColor: 'orange',
|
||||||
|
preview: JSON.stringify(JSON.parse(raw), null, 2),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
tagLabel: t('JSON 无效'),
|
||||||
|
tagColor: 'red',
|
||||||
|
preview: raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [paramTemplateDraft, t]);
|
||||||
|
|
||||||
|
const updateParamTemplateDraft = (value) => {
|
||||||
|
const next = typeof value === 'string' ? value : '';
|
||||||
|
setParamTemplateDraft(next);
|
||||||
|
if (modalFormRef.current) {
|
||||||
|
modalFormRef.current.setValue('param_override_template_json', next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatParamTemplateDraft = () => {
|
||||||
|
const raw = (paramTemplateDraft || '').trim();
|
||||||
|
if (!raw) return;
|
||||||
|
if (!verifyJSON(raw)) {
|
||||||
|
showError(t('参数覆盖模板 JSON 格式不正确'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
updateParamTemplateDraft(JSON.stringify(JSON.parse(raw), null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('参数覆盖模板 JSON 格式不正确'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openParamTemplatePreview = (rule) => {
|
||||||
|
const raw = rule?.param_override_template;
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
showWarning(t('该规则未设置参数覆盖模板'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.info({
|
||||||
|
title: t('参数覆盖模板预览'),
|
||||||
|
content: (
|
||||||
|
<div style={{ marginTop: 6, paddingBottom: 10 }}>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--semi-color-fill-0)',
|
||||||
|
border: '1px solid var(--semi-color-border)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stringifyPretty(raw)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
footer: null,
|
||||||
|
width: 760,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const refreshCacheStats = async () => {
|
const refreshCacheStats = async () => {
|
||||||
try {
|
try {
|
||||||
setCacheLoading(true);
|
setCacheLoading(true);
|
||||||
@@ -354,11 +451,15 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
.filter((x) => x.length > 0),
|
.filter((x) => x.length > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
const templates = [RULE_TEMPLATES.codex, RULE_TEMPLATES.claudeCode].map(
|
const templates = [
|
||||||
|
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
|
||||||
|
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
|
||||||
|
].map(
|
||||||
(tpl) => {
|
(tpl) => {
|
||||||
|
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||||
const name = makeUniqueName(existingNames, tpl.name);
|
const name = makeUniqueName(existingNames, tpl.name);
|
||||||
existingNames.add(name);
|
existingNames.add(name);
|
||||||
return { ...tpl, name };
|
return { ...baseTemplate, name };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,7 +477,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: t('填充 Codex / Claude Code 模版'),
|
title: t('填充 Codex CLI / Claude CLI 模版'),
|
||||||
content: (
|
content: (
|
||||||
<div style={{ lineHeight: '1.6' }}>
|
<div style={{ lineHeight: '1.6' }}>
|
||||||
<Text type='tertiary'>{t('将追加 2 条规则到现有规则列表。')}</Text>
|
<Text type='tertiary'>{t('将追加 2 条规则到现有规则列表。')}</Text>
|
||||||
@@ -416,18 +517,6 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
))
|
))
|
||||||
: '-',
|
: '-',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('User-Agent include'),
|
|
||||||
dataIndex: 'user_agent_include',
|
|
||||||
render: (list) =>
|
|
||||||
(list || []).length > 0
|
|
||||||
? (list || []).slice(0, 2).map((v, idx) => (
|
|
||||||
<Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
|
|
||||||
{v}
|
|
||||||
</Tag>
|
|
||||||
))
|
|
||||||
: '-',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('Key 来源'),
|
title: t('Key 来源'),
|
||||||
dataIndex: 'key_sources',
|
dataIndex: 'key_sources',
|
||||||
@@ -450,6 +539,24 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
dataIndex: 'ttl_seconds',
|
dataIndex: 'ttl_seconds',
|
||||||
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
|
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('覆盖模板'),
|
||||||
|
render: (_, record) => {
|
||||||
|
if (!record?.param_override_template) {
|
||||||
|
return <Text type='tertiary'>-</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
icon={<IconSearch />}
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => openParamTemplatePreview(record)}
|
||||||
|
>
|
||||||
|
{t('预览模板')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('缓存条目数'),
|
title: t('缓存条目数'),
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
@@ -539,7 +646,10 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
setEditingRule(nextRule);
|
setEditingRule(nextRule);
|
||||||
setIsEdit(false);
|
setIsEdit(false);
|
||||||
modalFormRef.current = null;
|
modalFormRef.current = null;
|
||||||
setModalInitValues(buildModalFormValues(nextRule));
|
const initValues = buildModalFormValues(nextRule);
|
||||||
|
setModalInitValues(initValues);
|
||||||
|
setParamTemplateDraft(initValues.param_override_template_json || '');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
setModalAdvancedActiveKey([]);
|
setModalAdvancedActiveKey([]);
|
||||||
setModalFormKey((k) => k + 1);
|
setModalFormKey((k) => k + 1);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
@@ -557,7 +667,10 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
setEditingRule(nextRule);
|
setEditingRule(nextRule);
|
||||||
setIsEdit(true);
|
setIsEdit(true);
|
||||||
modalFormRef.current = null;
|
modalFormRef.current = null;
|
||||||
setModalInitValues(buildModalFormValues(nextRule));
|
const initValues = buildModalFormValues(nextRule);
|
||||||
|
setModalInitValues(initValues);
|
||||||
|
setParamTemplateDraft(initValues.param_override_template_json || '');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
setModalAdvancedActiveKey([]);
|
setModalAdvancedActiveKey([]);
|
||||||
setModalFormKey((k) => k + 1);
|
setModalFormKey((k) => k + 1);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
@@ -582,6 +695,13 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
const userAgentInclude = normalizeStringList(
|
const userAgentInclude = normalizeStringList(
|
||||||
values.user_agent_include_text,
|
values.user_agent_include_text,
|
||||||
);
|
);
|
||||||
|
const paramTemplateValidation = parseOptionalObjectJson(
|
||||||
|
paramTemplateDraft,
|
||||||
|
'参数覆盖模板',
|
||||||
|
);
|
||||||
|
if (!paramTemplateValidation.ok) {
|
||||||
|
return showError(t(paramTemplateValidation.message));
|
||||||
|
}
|
||||||
|
|
||||||
const rulePayload = {
|
const rulePayload = {
|
||||||
id: isEdit ? editingRule.id : rules.length,
|
id: isEdit ? editingRule.id : rules.length,
|
||||||
@@ -599,6 +719,9 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
...(userAgentInclude.length > 0
|
...(userAgentInclude.length > 0
|
||||||
? { user_agent_include: userAgentInclude }
|
? { user_agent_include: userAgentInclude }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(paramTemplateValidation.value
|
||||||
|
? { param_override_template: paramTemplateValidation.value }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!rulePayload.name) return showError(t('名称不能为空'));
|
if (!rulePayload.name) return showError(t('名称不能为空'));
|
||||||
@@ -620,6 +743,8 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
setModalInitValues(null);
|
setModalInitValues(null);
|
||||||
|
setParamTemplateDraft('');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
showSuccess(t('保存成功'));
|
showSuccess(t('保存成功'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(t('请检查输入'));
|
showError(t('请检查输入'));
|
||||||
@@ -859,7 +984,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
{t('JSON 模式')}
|
{t('JSON 模式')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={appendCodexAndClaudeCodeTemplates}>
|
<Button onClick={appendCodexAndClaudeCodeTemplates}>
|
||||||
{t('填充 Codex / Claude Code 模版')}
|
{t('填充 Codex CLI / Claude CLI 模版')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<IconPlus />} onClick={openAddModal}>
|
<Button icon={<IconPlus />} onClick={openAddModal}>
|
||||||
{t('新增规则')}
|
{t('新增规则')}
|
||||||
@@ -919,6 +1044,8 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
setEditingRule(null);
|
setEditingRule(null);
|
||||||
setModalInitValues(null);
|
setModalInitValues(null);
|
||||||
setModalAdvancedActiveKey([]);
|
setModalAdvancedActiveKey([]);
|
||||||
|
setParamTemplateDraft('');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
}}
|
}}
|
||||||
onOk={handleModalSave}
|
onOk={handleModalSave}
|
||||||
okText={t('保存')}
|
okText={t('保存')}
|
||||||
@@ -1032,6 +1159,76 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong>{t('参数覆盖模板')}</Text>
|
||||||
|
</div>
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t(
|
||||||
|
'命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
background: 'var(--semi-color-fill-0)',
|
||||||
|
border: '1px solid var(--semi-color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag color={paramTemplatePreviewMeta.tagColor}>
|
||||||
|
{paramTemplatePreviewMeta.tagLabel}
|
||||||
|
</Tag>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
icon={<IconCode />}
|
||||||
|
onClick={() => setParamTemplateEditorVisible(true)}
|
||||||
|
>
|
||||||
|
{t('可视化编辑')}
|
||||||
|
</Button>
|
||||||
|
<Button size='small' onClick={formatParamTemplateDraft}>
|
||||||
|
{t('格式化')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='tertiary'
|
||||||
|
onClick={() => updateParamTemplateDraft('')}
|
||||||
|
>
|
||||||
|
{t('清空')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{paramTemplatePreviewMeta.preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
@@ -1159,6 +1356,16 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ParamOverrideEditorModal
|
||||||
|
visible={paramTemplateEditorVisible}
|
||||||
|
value={paramTemplateDraft || ''}
|
||||||
|
onSave={(nextValue) => {
|
||||||
|
updateParamTemplateDraft(nextValue || '');
|
||||||
|
setParamTemplateEditorVisible(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setParamTemplateEditorVisible(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user