/*
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 .
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, 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,
IconDelete,
IconEdit,
IconPlus,
IconRefresh,
} from '@douyinfe/semi-icons';
import {
API,
compareObjects,
showError,
showSuccess,
showWarning,
toBoolean,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
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 RULE_TEMPLATES = {
codex: {
name: 'codex优选',
model_regex: ['^gpt-.*$'],
path_regex: ['/v1/responses'],
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
value_regex: '',
ttl_seconds: 0,
include_using_group: true,
include_rule_name: true,
},
claudeCode: {
name: 'claude-code优选',
model_regex: ['^claude-.*$'],
path_regex: ['/v1/messages'],
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
value_regex: '',
ttl_seconds: 0,
include_using_group: true,
include_rule_name: true,
},
};
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,
"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();
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' };
}
};
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 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),
include_using_group: r.include_using_group ?? true,
include_rule_name: r.include_rule_name ?? true,
};
};
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: (
{t('将删除所有仍在内存中的渠道亲和性缓存条目。')}
),
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: (
{t('规则')}: {name}
),
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 = [RULE_TEMPLATES.codex, RULE_TEMPLATES.claudeCode].map(
(tpl) => {
const name = makeUniqueName(existingNames, tpl.name);
existingNames.add(name);
return { ...tpl, 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 / Claude Code 模版'),
content: (
{t('将追加 2 条规则到现有规则列表。')}
),
onOk: doAppend,
});
};
const ruleColumns = [
{
title: t('名称'),
dataIndex: 'name',
render: (text) => {text || '-'},
},
{
title: t('模型正则'),
dataIndex: 'model_regex',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 3).map((v, idx) => (
{v}
))
: '-',
},
{
title: t('路径正则'),
dataIndex: 'path_regex',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 2).map((v, idx) => (
{v}
))
: '-',
},
{
title: t('User-Agent include'),
dataIndex: 'user_agent_include',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 2).map((v, idx) => (
{v}
))
: '-',
},
{
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 (
{s.type}:{detail}
);
});
},
},
{
title: t('TTL(秒)'),
dataIndex: 'ttl_seconds',
render: (v) => {Number(v || 0) || '-'},
},
{
title: t('缓存条目数'),
render: (_, record) => {
const name = (record?.name || '').trim();
if (!name || !record?.include_rule_name) {
return N/A;
}
const n = Number(cacheStats?.by_rule_name?.[name] || 0);
return {n};
},
},
{
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) => (
{x}
));
},
},
{
title: t('操作'),
render: (_, record) => (
}
theme='borderless'
type='warning'
disabled={!record?.include_rule_name}
title={t('清空该规则缓存')}
aria-label={t('清空该规则缓存')}
onClick={() => confirmClearRuleCache(record)}
/>
}
theme='borderless'
title={t('编辑规则')}
aria-label={t('编辑规则')}
onClick={() => handleEditRule(record)}
/>
}
theme='borderless'
type='danger'
title={t('删除规则')}
aria-label={t('删除规则')}
onClick={() => handleDeleteRule(record.id)}
/>
),
},
];
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,
include_using_group: true,
include_rule_name: true,
};
setEditingRule(nextRule);
setIsEdit(false);
modalFormRef.current = null;
setModalInitValues(buildModalFormValues(nextRule));
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;
setModalInitValues(buildModalFormValues(nextRule));
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 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,
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
};
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);
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 = (
);
return (
<>
{banner}
setInputs({ ...inputs, [KEY_ENABLED]: value })
}
/>
{t('启用后将优先复用上一次成功的渠道(粘滞选路)。')}
{t(
'内存缓存最大条目数。0 表示使用后端默认容量:100000。',
)}
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_MAX_ENTRIES]: Number(value || 0),
})
}
/>
{t(
'规则 ttl_seconds 为 0 时使用。0 表示使用后端默认 TTL:3600 秒。',
)}
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_DEFAULT_TTL]: Number(value || 0),
})
}
/>
setInputs({ ...inputs, [KEY_SWITCH_ON_SUCCESS]: value })
}
/>
{t(
'如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。',
)}
} onClick={openAddModal}>
{t('新增规则')}
}
loading={cacheLoading}
onClick={refreshCacheStats}
>
{t('刷新缓存统计')}
{editMode === 'visual' ? (
) : (
verifyJSON(value || '[]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, [KEY_RULES]: value })
}
/>
)}
{
setModalVisible(false);
setEditingRule(null);
setModalInitValues(null);
setModalAdvancedActiveKey([]);
}}
onOk={handleModalSave}
okText={t('保存')}
cancelText={t('取消')}
width={720}
>
setEditingRule((prev) => ({ ...(prev || {}), name: value }))
}
/>
{
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setModalAdvancedActiveKey(keys.filter(Boolean));
}}
>
{t(
'可选。匹配入口请求的 User-Agent;任意一行作为子串匹配(忽略大小写)即命中。',
)}
{t(
'NewAPI 默认不会将入口请求的 User-Agent 透传到上游渠道;该条件仅用于识别访问本站点的客户端。',
)}
{t(
'为保证匹配准确,请确保客户端直连本站点(避免反向代理/网关改写 User-Agent)。',
)}
}
placeholder={'curl\nPostmanRuntime\nMyApp/…'}
autosize={{ minRows: 3, maxRows: 8 }}
/>
{t('该规则的缓存保留时长;0 表示使用默认 TTL:')}
{effectiveDefaultTTLSeconds}
{t(' 秒。')}
}
/>
{t(
'开启后,using_group 会参与 cache key(不同分组隔离)。',
)}
{t('开启后,规则名称会参与 cache key(不同规则隔离)。')}
{t('Key 来源')}
} onClick={addKeySource}>
{t('新增 Key 来源')}
{t(
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
)}
{t('常用上下文 Key(用于 context_*)')}:
{(CONTEXT_KEY_PRESETS || []).map((x) => (
{x.label}
))}
(