mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 07:37:23 +00:00
Merge pull request #3009 from seefs001/feature/improve-param-override
feat: improve channel override ui/ux
This commit is contained in:
2
web/package.json
vendored
2
web/package.json
vendored
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
|
||||
@@ -59,6 +59,7 @@ import ModelSelectModal from './ModelSelectModal';
|
||||
import SingleModelSelectModal from './SingleModelSelectModal';
|
||||
import OllamaModelModal from './OllamaModelModal';
|
||||
import CodexOAuthModal from './CodexOAuthModal';
|
||||
import ParamOverrideEditorModal from './ParamOverrideEditorModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
IconServer,
|
||||
IconSetting,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconGlobe,
|
||||
IconBolt,
|
||||
IconSearch,
|
||||
@@ -99,6 +101,28 @@ const REGION_EXAMPLE = {
|
||||
'claude-3-5-sonnet-20240620': 'europe-west1',
|
||||
};
|
||||
|
||||
const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
const PARAM_OVERRIDE_OPERATIONS_TEMPLATE = {
|
||||
operations: [
|
||||
{
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'prefix',
|
||||
value: 'openai/',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
|
||||
@@ -148,6 +172,7 @@ const EditChannelModal = (props) => {
|
||||
base_url: '',
|
||||
other: '',
|
||||
model_mapping: '',
|
||||
param_override: '',
|
||||
status_code_mapping: '',
|
||||
models: [],
|
||||
auto_ban: 1,
|
||||
@@ -251,11 +276,69 @@ const EditChannelModal = (props) => {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const paramOverrideMeta = useMemo(() => {
|
||||
const raw =
|
||||
typeof inputs.param_override === 'string'
|
||||
? inputs.param_override.trim()
|
||||
: '';
|
||||
if (!raw) {
|
||||
return {
|
||||
tagLabel: t('不更改'),
|
||||
tagColor: 'grey',
|
||||
preview: t(
|
||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!verifyJSON(raw)) {
|
||||
return {
|
||||
tagLabel: t('JSON格式错误'),
|
||||
tagColor: 'red',
|
||||
preview: raw,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const pretty = JSON.stringify(parsed, null, 2);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
Array.isArray(parsed.operations)
|
||||
) {
|
||||
return {
|
||||
tagLabel: `${t('新格式模板')} (${parsed.operations.length})`,
|
||||
tagColor: 'cyan',
|
||||
preview: pretty,
|
||||
};
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return {
|
||||
tagLabel: `${t('旧格式模板')} (${Object.keys(parsed).length})`,
|
||||
tagColor: 'blue',
|
||||
preview: pretty,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tagLabel: t('自定义 JSON'),
|
||||
tagColor: 'orange',
|
||||
preview: pretty,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
tagLabel: t('JSON格式错误'),
|
||||
tagColor: 'red',
|
||||
preview: raw,
|
||||
};
|
||||
}
|
||||
}, [inputs.param_override, t]);
|
||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
||||
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
|
||||
useState(false);
|
||||
const [paramOverrideEditorVisible, setParamOverrideEditorVisible] =
|
||||
useState(false);
|
||||
|
||||
// 密钥显示状态
|
||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||
@@ -582,6 +665,100 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyParamOverrideJson = async () => {
|
||||
const raw =
|
||||
typeof inputs.param_override === 'string'
|
||||
? inputs.param_override.trim()
|
||||
: '';
|
||||
if (!raw) {
|
||||
showInfo(t('暂无可复制 JSON'));
|
||||
return;
|
||||
}
|
||||
|
||||
let content = raw;
|
||||
if (verifyJSON(raw)) {
|
||||
try {
|
||||
content = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch (error) {
|
||||
content = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const ok = await copy(content);
|
||||
if (ok) {
|
||||
showSuccess(t('参数覆盖 JSON 已复制'));
|
||||
} else {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const parseParamOverrideInput = () => {
|
||||
const raw =
|
||||
typeof inputs.param_override === 'string'
|
||||
? inputs.param_override.trim()
|
||||
: '';
|
||||
if (!raw) return null;
|
||||
if (!verifyJSON(raw)) {
|
||||
throw new Error(t('当前参数覆盖不是合法的 JSON'));
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
const applyParamOverrideTemplate = (
|
||||
templateType = 'operations',
|
||||
applyMode = 'fill',
|
||||
) => {
|
||||
try {
|
||||
const parsedCurrent = parseParamOverrideInput();
|
||||
if (templateType === 'legacy') {
|
||||
if (applyMode === 'fill') {
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(PARAM_OVERRIDE_LEGACY_TEMPLATE, null, 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentLegacy =
|
||||
parsedCurrent &&
|
||||
typeof parsedCurrent === 'object' &&
|
||||
!Array.isArray(parsedCurrent) &&
|
||||
!Array.isArray(parsedCurrent.operations)
|
||||
? parsedCurrent
|
||||
: {};
|
||||
const merged = {
|
||||
...PARAM_OVERRIDE_LEGACY_TEMPLATE,
|
||||
...currentLegacy,
|
||||
};
|
||||
handleInputChange('param_override', JSON.stringify(merged, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (applyMode === 'fill') {
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(PARAM_OVERRIDE_OPERATIONS_TEMPLATE, null, 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentOperations =
|
||||
parsedCurrent &&
|
||||
typeof parsedCurrent === 'object' &&
|
||||
!Array.isArray(parsedCurrent) &&
|
||||
Array.isArray(parsedCurrent.operations)
|
||||
? parsedCurrent.operations
|
||||
: [];
|
||||
const merged = {
|
||||
operations: [
|
||||
...currentOperations,
|
||||
...PARAM_OVERRIDE_OPERATIONS_TEMPLATE.operations,
|
||||
],
|
||||
};
|
||||
handleInputChange('param_override', JSON.stringify(merged, null, 2));
|
||||
} catch (error) {
|
||||
showError(error.message || t('模板应用失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannel = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/channel/${channelId}`);
|
||||
@@ -1242,6 +1419,7 @@ const EditChannelModal = (props) => {
|
||||
const submit = async () => {
|
||||
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
||||
let localInputs = { ...formValues };
|
||||
localInputs.param_override = inputs.param_override;
|
||||
|
||||
if (localInputs.type === 57) {
|
||||
if (batch) {
|
||||
@@ -3150,78 +3328,73 @@ const EditChannelModal = (props) => {
|
||||
initValue={autoBan}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t(
|
||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||
) +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
'\n\n' +
|
||||
t('新格式(支持条件判断与json自定义):') +
|
||||
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
|
||||
}
|
||||
autosize
|
||||
onChange={(value) =>
|
||||
handleInputChange('param_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
<div className='mb-4'>
|
||||
<div className='flex items-center justify-between gap-2 mb-1'>
|
||||
<Text className='text-sm font-medium'>{t('参数覆盖')}</Text>
|
||||
<Space wrap>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
icon={<IconCode size={14} />}
|
||||
onClick={() => setParamOverrideEditorVisible(true)}
|
||||
>
|
||||
{t('可视化编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify({ temperature: 0 }, null, 2),
|
||||
)
|
||||
applyParamOverrideTemplate('operations', 'fill')
|
||||
}
|
||||
>
|
||||
{t('旧格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
{t('填充新模板')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'param_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'prefix',
|
||||
value: 'gpt',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
applyParamOverrideTemplate('legacy', 'fill')
|
||||
}
|
||||
>
|
||||
{t('新格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => formatJsonField('param_override')}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Text>
|
||||
{t('填充旧模板')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数')}
|
||||
</Text>
|
||||
<div
|
||||
className='mt-2 rounded-xl p-3'
|
||||
style={{
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
border: '1px solid var(--semi-color-fill-2)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Tag color={paramOverrideMeta.tagColor}>
|
||||
{paramOverrideMeta.tagLabel}
|
||||
</Tag>
|
||||
<Space spacing={8}>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<IconCopy />}
|
||||
type='tertiary'
|
||||
onClick={copyParamOverrideJson}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => setParamOverrideEditorVisible(true)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<pre className='mb-0 text-xs leading-5 whitespace-pre-wrap break-all max-h-56 overflow-auto'>
|
||||
{paramOverrideMeta.preview}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.TextArea
|
||||
field='header_override'
|
||||
@@ -3641,6 +3814,16 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ParamOverrideEditorModal
|
||||
visible={paramOverrideEditorVisible}
|
||||
value={inputs.param_override || ''}
|
||||
onCancel={() => setParamOverrideEditorVisible(false)}
|
||||
onSave={(nextValue) => {
|
||||
handleInputChange('param_override', nextValue);
|
||||
setParamOverrideEditorVisible(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModelSelectModal
|
||||
visible={modelModalVisible}
|
||||
models={fetchedModels}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
90
web/src/constants/channel-affinity-template.constants.js
vendored
Normal file
90
web/src/constants/channel-affinity-template.constants.js
vendored
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 || {}));
|
||||
1
web/src/constants/index.js
vendored
1
web/src/constants/index.js
vendored
@@ -24,3 +24,4 @@ export * from './common.constant';
|
||||
export * from './dashboard.constants';
|
||||
export * from './playground.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
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -37,10 +37,12 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconClose,
|
||||
IconCode,
|
||||
IconDelete,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
API,
|
||||
@@ -52,6 +54,11 @@ import {
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
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_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
|
||||
@@ -65,31 +72,6 @@ const KEY_SOURCE_TYPES = [
|
||||
{ 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 = [
|
||||
{ key: 'id', label: 'id(用户 ID)' },
|
||||
{ key: 'token_id', label: 'token_id' },
|
||||
@@ -114,6 +96,11 @@ const RULES_JSON_PLACEHOLDER = `[
|
||||
],
|
||||
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
|
||||
"ttl_seconds": 600,
|
||||
"param_override_template": {
|
||||
"operations": [
|
||||
{ "path": "temperature", "mode": "set", "value": 0.2 }
|
||||
]
|
||||
},
|
||||
"skip_retry_on_failure": false,
|
||||
"include_using_group": 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) {
|
||||
const { t } = useTranslation();
|
||||
const { Text } = Typography;
|
||||
@@ -222,6 +226,9 @@ export default function SettingsChannelAffinity(props) {
|
||||
const [modalInitValues, setModalInitValues] = useState(null);
|
||||
const [modalFormKey, setModalFormKey] = useState(0);
|
||||
const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
|
||||
const [paramTemplateDraft, setParamTemplateDraft] = useState('');
|
||||
const [paramTemplateEditorVisible, setParamTemplateEditorVisible] =
|
||||
useState(false);
|
||||
|
||||
const effectiveDefaultTTLSeconds =
|
||||
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,
|
||||
include_using_group: r.include_using_group ?? 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 () => {
|
||||
try {
|
||||
setCacheLoading(true);
|
||||
@@ -354,11 +451,15 @@ export default function SettingsChannelAffinity(props) {
|
||||
.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) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...tpl, name };
|
||||
return { ...baseTemplate, name };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -376,7 +477,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: t('填充 Codex / Claude Code 模版'),
|
||||
title: t('填充 Codex CLI / Claude CLI 模版'),
|
||||
content: (
|
||||
<div style={{ lineHeight: '1.6' }}>
|
||||
<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 来源'),
|
||||
dataIndex: 'key_sources',
|
||||
@@ -450,6 +539,24 @@ export default function SettingsChannelAffinity(props) {
|
||||
dataIndex: 'ttl_seconds',
|
||||
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('缓存条目数'),
|
||||
render: (_, record) => {
|
||||
@@ -539,7 +646,10 @@ export default function SettingsChannelAffinity(props) {
|
||||
setEditingRule(nextRule);
|
||||
setIsEdit(false);
|
||||
modalFormRef.current = null;
|
||||
setModalInitValues(buildModalFormValues(nextRule));
|
||||
const initValues = buildModalFormValues(nextRule);
|
||||
setModalInitValues(initValues);
|
||||
setParamTemplateDraft(initValues.param_override_template_json || '');
|
||||
setParamTemplateEditorVisible(false);
|
||||
setModalAdvancedActiveKey([]);
|
||||
setModalFormKey((k) => k + 1);
|
||||
setModalVisible(true);
|
||||
@@ -557,7 +667,10 @@ export default function SettingsChannelAffinity(props) {
|
||||
setEditingRule(nextRule);
|
||||
setIsEdit(true);
|
||||
modalFormRef.current = null;
|
||||
setModalInitValues(buildModalFormValues(nextRule));
|
||||
const initValues = buildModalFormValues(nextRule);
|
||||
setModalInitValues(initValues);
|
||||
setParamTemplateDraft(initValues.param_override_template_json || '');
|
||||
setParamTemplateEditorVisible(false);
|
||||
setModalAdvancedActiveKey([]);
|
||||
setModalFormKey((k) => k + 1);
|
||||
setModalVisible(true);
|
||||
@@ -582,6 +695,13 @@ export default function SettingsChannelAffinity(props) {
|
||||
const userAgentInclude = normalizeStringList(
|
||||
values.user_agent_include_text,
|
||||
);
|
||||
const paramTemplateValidation = parseOptionalObjectJson(
|
||||
paramTemplateDraft,
|
||||
'参数覆盖模板',
|
||||
);
|
||||
if (!paramTemplateValidation.ok) {
|
||||
return showError(t(paramTemplateValidation.message));
|
||||
}
|
||||
|
||||
const rulePayload = {
|
||||
id: isEdit ? editingRule.id : rules.length,
|
||||
@@ -599,6 +719,9 @@ export default function SettingsChannelAffinity(props) {
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramTemplateValidation.value
|
||||
? { param_override_template: paramTemplateValidation.value }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (!rulePayload.name) return showError(t('名称不能为空'));
|
||||
@@ -620,6 +743,8 @@ export default function SettingsChannelAffinity(props) {
|
||||
setModalVisible(false);
|
||||
setEditingRule(null);
|
||||
setModalInitValues(null);
|
||||
setParamTemplateDraft('');
|
||||
setParamTemplateEditorVisible(false);
|
||||
showSuccess(t('保存成功'));
|
||||
} catch (e) {
|
||||
showError(t('请检查输入'));
|
||||
@@ -859,7 +984,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
{t('JSON 模式')}
|
||||
</Button>
|
||||
<Button onClick={appendCodexAndClaudeCodeTemplates}>
|
||||
{t('填充 Codex / Claude Code 模版')}
|
||||
{t('填充 Codex CLI / Claude CLI 模版')}
|
||||
</Button>
|
||||
<Button icon={<IconPlus />} onClick={openAddModal}>
|
||||
{t('新增规则')}
|
||||
@@ -919,6 +1044,8 @@ export default function SettingsChannelAffinity(props) {
|
||||
setEditingRule(null);
|
||||
setModalInitValues(null);
|
||||
setModalAdvancedActiveKey([]);
|
||||
setParamTemplateDraft('');
|
||||
setParamTemplateEditorVisible(false);
|
||||
}}
|
||||
onOk={handleModalSave}
|
||||
okText={t('保存')}
|
||||
@@ -1032,6 +1159,76 @@ export default function SettingsChannelAffinity(props) {
|
||||
</Col>
|
||||
</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}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Switch
|
||||
@@ -1159,6 +1356,16 @@ export default function SettingsChannelAffinity(props) {
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<ParamOverrideEditorModal
|
||||
visible={paramTemplateEditorVisible}
|
||||
value={paramTemplateDraft || ''}
|
||||
onSave={(nextValue) => {
|
||||
updateParamTemplateDraft(nextValue || '');
|
||||
setParamTemplateEditorVisible(false);
|
||||
}}
|
||||
onCancel={() => setParamTemplateEditorVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user