test-napi / web /src /pages /Setting /Operation /SettingsChannelAffinity.jsx
Kyou0203's picture
init for HF Space
daa8246
/*
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
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Banner,
Button,
Col,
Collapse,
Divider,
Form,
Input,
Modal,
Row,
Select,
Space,
Spin,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconClose,
IconCode,
IconDelete,
IconEdit,
IconPlus,
IconRefresh,
IconSearch,
} from '@douyinfe/semi-icons';
import {
API,
compareObjects,
showError,
showSuccess,
showWarning,
toBoolean,
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';
const KEY_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
const KEY_RULES = 'channel_affinity_setting.rules';
const KEY_SOURCE_TYPES = [
{ label: 'context_int', value: 'context_int' },
{ label: 'context_string', value: 'context_string' },
{ label: 'gjson', value: 'gjson' },
];
const CONTEXT_KEY_PRESETS = [
{ key: 'id', label: 'id(用户 ID)' },
{ key: 'token_id', label: 'token_id' },
{ key: 'token_key', label: 'token_key' },
{ key: 'token_group', label: 'token_group' },
{ key: 'group', label: 'group(using_group)' },
{ key: 'username', label: 'username' },
{ key: 'user_group', label: 'user_group' },
{ key: 'user_email', label: 'user_email' },
{ key: 'specific_channel_id', label: 'specific_channel_id' },
];
const RULES_JSON_PLACEHOLDER = `[
{
"name": "prefer-by-conversation-id",
"model_regex": ["^gpt-.*$"],
"path_regex": ["/v1/chat/completions"],
"user_agent_include": ["curl", "PostmanRuntime"],
"key_sources": [
{ "type": "gjson", "path": "metadata.conversation_id" },
{ "type": "context_string", "key": "conversation_id" }
],
"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
}
]`;
const normalizeStringList = (text) => {
if (!text) return [];
return text
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
};
const stringifyPretty = (v) => JSON.stringify(v, null, 2);
const stringifyCompact = (v) => JSON.stringify(v);
const parseRulesJson = (jsonString) => {
try {
const parsed = JSON.parse(jsonString || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map((rule, index) => ({
id: index,
...(rule || {}),
}));
} catch (e) {
return [];
}
};
const rulesToJson = (rules) => {
const payload = (rules || []).map((r) => {
const { id, ...rest } = r || {};
return rest;
});
return stringifyPretty(payload);
};
const normalizeKeySource = (src) => {
const type = (src?.type || '').trim();
const key = (src?.key || '').trim();
const path = (src?.path || '').trim();
if (type === 'gjson') {
return { type, key: '', path };
}
return { type, key, path: '' };
};
const makeUniqueName = (existingNames, baseName) => {
const base = (baseName || '').trim() || 'rule';
if (!existingNames.has(base)) return base;
for (let i = 2; i < 1000; i++) {
const n = `${base}-${i}`;
if (!existingNames.has(n)) return n;
}
return `${base}-${Date.now()}`;
};
const tryParseRulesJsonArray = (jsonString) => {
const raw = jsonString || '[]';
if (!verifyJSON(raw)) return { ok: false, message: 'Rules JSON is invalid' };
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed))
return { ok: false, message: 'Rules JSON must be an array' };
return { ok: true, value: parsed };
} catch (e) {
return { ok: false, message: 'Rules JSON is invalid' };
}
};
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;
const [loading, setLoading] = useState(false);
const [cacheLoading, setCacheLoading] = useState(false);
const [cacheStats, setCacheStats] = useState({
enabled: false,
total: 0,
unknown: 0,
by_rule_name: {},
cache_capacity: 0,
cache_algo: '',
});
const [inputs, setInputs] = useState({
[KEY_ENABLED]: false,
[KEY_SWITCH_ON_SUCCESS]: true,
[KEY_MAX_ENTRIES]: 100000,
[KEY_DEFAULT_TTL]: 3600,
[KEY_RULES]: '[]',
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [editMode, setEditMode] = useState('visual');
const prevEditModeRef = useRef(editMode);
const [rules, setRules] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingRule, setEditingRule] = useState(null);
const [isEdit, setIsEdit] = useState(false);
const modalFormRef = useRef();
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
? Number(inputs?.[KEY_DEFAULT_TTL] || 0)
: 3600;
const buildModalFormValues = (rule) => {
const r = rule || {};
return {
name: r.name || '',
model_regex_text: (r.model_regex || []).join('\n'),
path_regex_text: (r.path_regex || []).join('\n'),
user_agent_include_text: (r.user_agent_include || []).join('\n'),
value_regex: r.value_regex || '',
ttl_seconds: Number(r.ttl_seconds || 0),
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);
const res = await API.get('/api/option/channel_affinity_cache', {
disableDuplicate: true,
});
const { success, message, data } = res.data;
if (!success) return showError(t(message));
setCacheStats(data || {});
} catch (e) {
showError(t('刷新缓存统计失败'));
} finally {
setCacheLoading(false);
}
};
const confirmClearAllCache = () => {
Modal.confirm({
title: t('确认清空全部渠道亲和性缓存'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text>{t('将删除所有仍在内存中的渠道亲和性缓存条目。')}</Text>
</div>
),
onOk: async () => {
const res = await API.delete('/api/option/channel_affinity_cache', {
params: { all: true },
});
const { success, message } = res.data;
if (!success) {
showError(t(message));
return;
}
showSuccess(t('已清空'));
await refreshCacheStats();
},
});
};
const confirmClearRuleCache = (rule) => {
const name = (rule?.name || '').trim();
if (!name) return;
if (!rule?.include_rule_name) {
showWarning(
t('该规则未启用“作用域:包含规则名称”,无法按规则清空缓存。'),
);
return;
}
Modal.confirm({
title: t('确认清空该规则缓存'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text>{t('规则')}:</Text> <Text strong>{name}</Text>
</div>
),
onOk: async () => {
const res = await API.delete('/api/option/channel_affinity_cache', {
params: { rule_name: name },
});
const { success, message } = res.data;
if (!success) {
showError(t(message));
return;
}
showSuccess(t('已清空'));
await refreshCacheStats();
},
});
};
const setRulesJsonToForm = (jsonString) => {
if (!refForm.current) return;
// Use setValue instead of setValues. Semi Form's setValues assigns undefined
// to every registered field not included in the payload, which can wipe other inputs.
refForm.current.setValue(KEY_RULES, jsonString || '[]');
};
const switchToJsonMode = () => {
// Ensure a stable source of truth when entering JSON mode.
// Semi Form may ignore setValues() for an unmounted field, so we seed state first.
const jsonString = rulesToJson(rules);
setInputs((prev) => ({ ...(prev || {}), [KEY_RULES]: jsonString }));
setEditMode('json');
};
const switchToVisualMode = () => {
const validation = tryParseRulesJsonArray(inputs[KEY_RULES] || '[]');
if (!validation.ok) {
showError(t(validation.message));
return;
}
setEditMode('visual');
};
const updateRulesState = (nextRules) => {
setRules(nextRules);
const jsonString = rulesToJson(nextRules);
setInputs((prev) => ({ ...prev, [KEY_RULES]: jsonString }));
if (refForm.current && editMode === 'json') {
refForm.current.setValue(KEY_RULES, jsonString);
}
};
const appendCodexAndClaudeCodeTemplates = () => {
const doAppend = () => {
const existingNames = new Set(
(rules || [])
.map((r) => (r?.name || '').trim())
.filter((x) => x.length > 0),
);
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 { ...baseTemplate, name };
},
);
const next = [...(rules || []), ...templates].map((r, idx) => ({
...(r || {}),
id: idx,
}));
updateRulesState(next);
showSuccess(t('已填充模版'));
};
if ((rules || []).length === 0) {
doAppend();
return;
}
Modal.confirm({
title: t('填充 Codex CLI / Claude CLI 模版'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text type='tertiary'>{t('将追加 2 条规则到现有规则列表。')}</Text>
</div>
),
onOk: doAppend,
});
};
const ruleColumns = [
{
title: t('名称'),
dataIndex: 'name',
render: (text) => <Text>{text || '-'}</Text>,
},
{
title: t('模型正则'),
dataIndex: 'model_regex',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 3).map((v, idx) => (
<Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
{v}
</Tag>
))
: '-',
},
{
title: t('路径正则'),
dataIndex: 'path_regex',
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',
render: (list) => {
const xs = list || [];
if (xs.length === 0) return '-';
return xs.slice(0, 3).map((src, idx) => {
const s = normalizeKeySource(src);
const detail = s.type === 'gjson' ? s.path : s.key;
return (
<Tag key={`${s.type}-${idx}`} style={{ marginRight: 4 }}>
{s.type}:{detail}
</Tag>
);
});
},
},
{
title: t('TTL(秒)'),
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) => {
const name = (record?.name || '').trim();
if (!name || !record?.include_rule_name) {
return <Text type='tertiary'>N/A</Text>;
}
const n = Number(cacheStats?.by_rule_name?.[name] || 0);
return <Text>{n}</Text>;
},
},
{
title: t('作用域'),
render: (_, record) => {
const tags = [];
if (record?.include_using_group) tags.push('分组');
if (record?.include_rule_name) tags.push('规则');
if (tags.length === 0) return '-';
return tags.map((x) => (
<Tag key={x} style={{ marginRight: 4 }}>
{x}
</Tag>
));
},
},
{
title: t('操作'),
render: (_, record) => (
<Space>
<Button
icon={<IconClose />}
theme='borderless'
type='warning'
disabled={!record?.include_rule_name}
title={t('清空该规则缓存')}
aria-label={t('清空该规则缓存')}
onClick={() => confirmClearRuleCache(record)}
/>
<Button
icon={<IconEdit />}
theme='borderless'
title={t('编辑规则')}
aria-label={t('编辑规则')}
onClick={() => handleEditRule(record)}
/>
<Button
icon={<IconDelete />}
theme='borderless'
type='danger'
title={t('删除规则')}
aria-label={t('删除规则')}
onClick={() => handleDeleteRule(record.id)}
/>
</Space>
),
},
];
const validateKeySources = (keySources) => {
const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
for (const x of xs) {
if (x.type === 'context_int' || x.type === 'context_string') {
if (!x.key) return { ok: false, message: 'Key 不能为空' };
} else if (x.type === 'gjson') {
if (!x.path) return { ok: false, message: 'Path 不能为空' };
} else {
return { ok: false, message: 'Key 来源类型不合法' };
}
}
return { ok: true, value: xs };
};
const openAddModal = () => {
const nextRule = {
name: '',
model_regex: [],
path_regex: [],
user_agent_include: [],
key_sources: [{ type: 'gjson', path: '' }],
value_regex: '',
ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true,
include_rule_name: true,
};
setEditingRule(nextRule);
setIsEdit(false);
modalFormRef.current = null;
const initValues = buildModalFormValues(nextRule);
setModalInitValues(initValues);
setParamTemplateDraft(initValues.param_override_template_json || '');
setParamTemplateEditorVisible(false);
setModalAdvancedActiveKey([]);
setModalFormKey((k) => k + 1);
setModalVisible(true);
};
const handleEditRule = (rule) => {
const r = rule || {};
const nextRule = {
...r,
user_agent_include: Array.isArray(r.user_agent_include)
? r.user_agent_include
: [],
key_sources: (r.key_sources || []).map(normalizeKeySource),
};
setEditingRule(nextRule);
setIsEdit(true);
modalFormRef.current = null;
const initValues = buildModalFormValues(nextRule);
setModalInitValues(initValues);
setParamTemplateDraft(initValues.param_override_template_json || '');
setParamTemplateEditorVisible(false);
setModalAdvancedActiveKey([]);
setModalFormKey((k) => k + 1);
setModalVisible(true);
};
const handleDeleteRule = (id) => {
const next = (rules || []).filter((r) => r.id !== id);
updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
showSuccess(t('删除成功'));
};
const handleModalSave = async () => {
try {
const values = await modalFormRef.current.validate();
const modelRegex = normalizeStringList(values.model_regex_text);
if (modelRegex.length === 0) return showError(t('模型正则不能为空'));
const keySourcesValidation = validateKeySources(editingRule?.key_sources);
if (!keySourcesValidation.ok)
return showError(t(keySourcesValidation.message));
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,
name: (values.name || '').trim(),
model_regex: modelRegex,
path_regex: normalizeStringList(values.path_regex_text),
key_sources: keySourcesValidation.value,
value_regex: (values.value_regex || '').trim(),
ttl_seconds: Number(values.ttl_seconds || 0),
include_using_group: !!values.include_using_group,
include_rule_name: !!values.include_rule_name,
...(values.skip_retry_on_failure
? { skip_retry_on_failure: true }
: {}),
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
...(paramTemplateValidation.value
? { param_override_template: paramTemplateValidation.value }
: {}),
};
if (!rulePayload.name) return showError(t('名称不能为空'));
const next = [...(rules || [])];
if (isEdit) {
let idx = next.findIndex((r) => r.id === editingRule?.id);
if (idx < 0 && editingRule?.name) {
idx = next.findIndex(
(r) => (r?.name || '').trim() === (editingRule?.name || '').trim(),
);
}
if (idx < 0) return showError(t('规则未找到,请刷新后重试'));
next[idx] = rulePayload;
} else {
next.push(rulePayload);
}
updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
setModalVisible(false);
setEditingRule(null);
setModalInitValues(null);
setParamTemplateDraft('');
setParamTemplateEditorVisible(false);
showSuccess(t('保存成功'));
} catch (e) {
showError(t('请检查输入'));
}
};
const updateKeySource = (index, patch) => {
const next = [...(editingRule?.key_sources || [])];
next[index] = normalizeKeySource({
...(next[index] || {}),
...(patch || {}),
});
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
const addKeySource = () => {
const next = [...(editingRule?.key_sources || [])];
next.push({ type: 'gjson', path: '' });
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
const removeKeySource = (index) => {
const next = [...(editingRule?.key_sources || [])].filter(
(_, i) => i !== index,
);
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
async function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
if (!verifyJSON(inputs[KEY_RULES] || '[]'))
return showError(t('规则 JSON 格式不正确'));
let compactRules;
try {
compactRules = stringifyCompact(JSON.parse(inputs[KEY_RULES] || '[]'));
} catch (e) {
return showError(t('规则 JSON 格式不正确'));
}
const requestQueue = updateArray.map((item) => {
let value = '';
if (item.key === KEY_RULES) {
value = compactRules;
} else if (typeof inputs[item.key] === 'boolean') {
value = String(inputs[item.key]);
} else {
value = String(inputs[item.key] ?? '');
}
return API.put('/api/option/', { key: item.key, value });
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => showError(t('保存失败,请重试')))
.finally(() => setLoading(false));
}
useEffect(() => {
const currentInputs = { ...inputs };
for (let key in props.options) {
if (
![
KEY_ENABLED,
KEY_SWITCH_ON_SUCCESS,
KEY_MAX_ENTRIES,
KEY_DEFAULT_TTL,
KEY_RULES,
].includes(key)
)
continue;
if (key === KEY_ENABLED)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_SWITCH_ON_SUCCESS)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_MAX_ENTRIES)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_DEFAULT_TTL)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_RULES) {
try {
const obj = JSON.parse(props.options[key] || '[]');
currentInputs[key] = stringifyPretty(obj);
} catch (e) {
currentInputs[key] = props.options[key] || '[]';
}
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
if (refForm.current) refForm.current.setValues(currentInputs);
setRules(parseRulesJson(currentInputs[KEY_RULES]));
refreshCacheStats();
}, [props.options]);
useEffect(() => {
const prevEditMode = prevEditModeRef.current;
prevEditModeRef.current = editMode;
// On switching from visual -> json, ensure the JSON editor is seeded.
// Semi Form may ignore setValues() for an unmounted field.
if (prevEditMode === editMode) return;
if (editMode !== 'json') return;
if (!refForm.current) return;
refForm.current.setValue(KEY_RULES, inputs[KEY_RULES] || '[]');
}, [editMode, inputs]);
useEffect(() => {
if (editMode === 'visual') {
setRules(parseRulesJson(inputs[KEY_RULES]));
}
}, [inputs[KEY_RULES], editMode]);
const banner = (
<Banner
fullMode={false}
type='info'
description={t(
'渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key优先复用上一次成功的渠道。',
)}
/>
);
return (
<>
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('渠道亲和性')}>
{banner}
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_ENABLED}
label={t('启用')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({ ...inputs, [KEY_ENABLED]: value })
}
/>
<Text type='tertiary' size='small'>
{t('启用后将优先复用上一次成功的渠道(粘滞选路)。')}
</Text>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field={KEY_MAX_ENTRIES}
label={t('最大条目数')}
min={0}
placeholder='例如 100000…'
extraText={
<Text type='tertiary' size='small'>
{t(
'内存缓存最大条目数。0 表示使用后端默认容量:100000。',
)}
</Text>
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_MAX_ENTRIES]: Number(value || 0),
})
}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field={KEY_DEFAULT_TTL}
label={t('默认 TTL)')}
min={0}
placeholder='例如 3600…'
extraText={
<Text type='tertiary' size='small'>
{t(
'规则 ttl_seconds 为 0 时使用。0 表示使用后端默认 TTL:3600 秒。',
)}
</Text>
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_DEFAULT_TTL]: Number(value || 0),
})
}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_SWITCH_ON_SUCCESS}
label={t('成功后切换亲和')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({ ...inputs, [KEY_SWITCH_ON_SUCCESS]: value })
}
/>
<Text type='tertiary' size='small'>
{t(
'如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。',
)}
</Text>
</Col>
</Row>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Space style={{ marginBottom: 10 }}>
<Button
type={editMode === 'visual' ? 'primary' : 'tertiary'}
onClick={switchToVisualMode}
>
{t('可视化')}
</Button>
<Button
type={editMode === 'json' ? 'primary' : 'tertiary'}
onClick={switchToJsonMode}
>
{t('JSON 模式')}
</Button>
<Button onClick={appendCodexAndClaudeCodeTemplates}>
{t('填充 Codex CLI / Claude CLI 模版')}
</Button>
<Button icon={<IconPlus />} onClick={openAddModal}>
{t('新增规则')}
</Button>
<Button theme='solid' onClick={onSubmit}>
{t('保存')}
</Button>
<Button
icon={<IconRefresh />}
loading={cacheLoading}
onClick={refreshCacheStats}
>
{t('刷新缓存统计')}
</Button>
<Button type='danger' onClick={confirmClearAllCache}>
{t('清空全部缓存')}
</Button>
</Space>
{editMode === 'visual' ? (
<Table
columns={ruleColumns}
dataSource={rules}
rowKey='id'
pagination={false}
size='small'
/>
) : (
<Form.TextArea
field={KEY_RULES}
label={t('规则 JSON')}
extraText={t(
'规则为 JSON 数组可视化与 JSON 模式共用同一份数据。',
)}
placeholder={RULES_JSON_PLACEHOLDER}
style={{ width: '100%' }}
autosize={{ minRows: 10, maxRows: 28 }}
rules={[
{
validator: (rule, value) => verifyJSON(value || '[]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, [KEY_RULES]: value })
}
/>
)}
</Form.Section>
</Form>
</Spin>
<Modal
title={isEdit ? t('编辑规则') : t('新增规则')}
visible={modalVisible}
onCancel={() => {
setModalVisible(false);
setEditingRule(null);
setModalInitValues(null);
setModalAdvancedActiveKey([]);
setParamTemplateDraft('');
setParamTemplateEditorVisible(false);
}}
onOk={handleModalSave}
okText={t('保存')}
cancelText={t('取消')}
width={720}
>
<Form
key={`channel-affinity-rule-form-${modalFormKey}`}
initValues={modalInitValues || {}}
getFormApi={(formAPI) => {
modalFormRef.current = formAPI;
}}
>
<Form.Input
field='name'
label={t('名称')}
extraText={t('规则名称可读性更好也会出现在管理侧日志中)。')}
placeholder='例如 prefer-by-conversation-id…'
rules={[{ required: true }]}
onChange={(value) =>
setEditingRule((prev) => ({ ...(prev || {}), name: value }))
}
/>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.TextArea
field='model_regex_text'
label={t('模型正则每行一个)')}
extraText={t(
'必填对请求的 model 名称进行匹配任意一条匹配即命中该规则。',
)}
placeholder={'^gpt-4o.*$\n^claude-3.*$…'}
autosize={{ minRows: 4, maxRows: 10 }}
rules={[{ required: true }]}
/>
</Col>
<Col xs={24} sm={12}>
<Form.TextArea
field='path_regex_text'
label={t('路径正则每行一个)')}
extraText={t(
'可选对请求路径进行匹配不填表示匹配所有路径。',
)}
placeholder={'/v1/chat/completions\n/v1/responses…'}
autosize={{ minRows: 4, maxRows: 10 }}
/>
</Col>
</Row>
<Collapse
keepDOM
activeKey={modalAdvancedActiveKey}
onChange={(activeKey) => {
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setModalAdvancedActiveKey(keys.filter(Boolean));
}}
>
<Collapse.Panel header={t('高级设置')} itemKey='advanced'>
<Row gutter={16}>
<Col xs={24}>
<Form.TextArea
field='user_agent_include_text'
label={t('User-Agent include每行一个可不写)')}
extraText={
<Text type='tertiary' size='small'>
{t(
'可选。匹配入口请求的 User-Agent;任意一行作为子串匹配(忽略大小写)即命中。',
)}
<br />
{t(
'NewAPI 默认不会将入口请求的 User-Agent 透传到上游渠道;该条件仅用于识别访问本站点的客户端。',
)}
<br />
{t(
'为保证匹配准确,请确保客户端直连本站点(避免反向代理/网关改写 User-Agent)。',
)}
</Text>
}
placeholder={'curl\nPostmanRuntime\nMyApp/…'}
autosize={{ minRows: 3, maxRows: 8 }}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Input
field='value_regex'
label={t('Value 正则')}
placeholder='^[-0-9A-Za-z._:]{1,128}$'
extraText={t(
'可选对提取到的亲和 Key 做正则校验不填表示不校验。',
)}
/>
</Col>
<Col xs={24} sm={12}>
<Form.InputNumber
field='ttl_seconds'
label={t('TTL0 表示默认)')}
placeholder='例如 600…'
min={0}
extraText={
<Text type='tertiary' size='small'>
{t('该规则的缓存保留时长;0 表示使用默认 TTL:')}
{effectiveDefaultTTLSeconds}
{t(' 秒。')}
</Text>
}
/>
</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
field='include_using_group'
label={t('作用域包含分组')}
/>
<Text type='tertiary' size='small'>
{t(
'开启后,using_group 会参与 cache key(不同分组隔离)。',
)}
</Text>
</Col>
<Col xs={24} sm={12}>
<Form.Switch
field='include_rule_name'
label={t('作用域包含规则名称')}
/>
<Text type='tertiary' size='small'>
{t('开启后,规则名称会参与 cache key(不同规则隔离)。')}
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
</Collapse.Panel>
</Collapse>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Space style={{ marginBottom: 10 }}>
<Text>{t('Key 来源')}</Text>
<Button icon={<IconPlus />} onClick={addKeySource}>
{t('新增 Key 来源')}
</Button>
</Space>
<Text type='tertiary' size='small'>
{t(
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
)}
</Text>
<div style={{ marginTop: 8, marginBottom: 8 }}>
<Text type='tertiary' size='small'>
{t('常用上下文 Key(用于 context_*)')}:
</Text>
<div style={{ marginTop: 6 }}>
{(CONTEXT_KEY_PRESETS || []).map((x) => (
<Tag key={x.key} style={{ marginRight: 6, marginBottom: 6 }}>
{x.label}
</Tag>
))}
</div>
</div>
<Table
columns={[
{
title: t('类型'),
render: (_, __, idx) => (
<Select
style={{ width: 160 }}
optionList={KEY_SOURCE_TYPES}
value={(
editingRule?.key_sources?.[idx]?.type || 'gjson'
).trim()}
aria-label={t('Key 来源类型')}
onChange={(value) => updateKeySource(idx, { type: value })}
/>
),
},
{
title: t('Key 或 Path'),
render: (_, __, idx) => {
const src = normalizeKeySource(
editingRule?.key_sources?.[idx],
);
const isGjson = src.type === 'gjson';
return (
<Input
placeholder={
isGjson ? 'metadata.conversation_id' : 'user_id'
}
aria-label={t('Key Path')}
value={isGjson ? src.path : src.key}
onChange={(value) =>
updateKeySource(
idx,
isGjson ? { path: value } : { key: value },
)
}
/>
);
},
},
{
title: t('操作'),
width: 90,
render: (_, __, idx) => (
<Button
icon={<IconDelete />}
theme='borderless'
type='danger'
title={t('删除 Key 来源')}
aria-label={t('删除 Key 来源')}
onClick={() => removeKeySource(idx)}
/>
),
},
]}
dataSource={(editingRule?.key_sources || []).map((x, idx) => ({
id: idx,
...x,
}))}
rowKey='id'
pagination={false}
size='small'
/>
</Form>
</Modal>
<ParamOverrideEditorModal
visible={paramTemplateEditorVisible}
value={paramTemplateDraft || ''}
onSave={(nextValue) => {
updateParamTemplateDraft(nextValue || '');
setParamTemplateEditorVisible(false);
}}
onCancel={() => setParamTemplateEditorVisible(false)}
/>
</>
);
}