/* 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, 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: (
            {stringifyPretty(raw)}
          
), 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: (
{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 = [ 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: (
{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('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) => { if (!record?.param_override_template) { return -; } return ( ); }, }, { 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) => ( {editMode === 'visual' ? ( ) : ( verifyJSON(value || '[]'), }, ]} onChange={(value) => setInputs({ ...inputs, [KEY_RULES]: value }) } /> )} { setModalVisible(false); setEditingRule(null); setModalInitValues(null); setModalAdvancedActiveKey([]); setParamTemplateDraft(''); setParamTemplateEditorVisible(false); }} onOk={handleModalSave} okText={t('保存')} cancelText={t('取消')} width={720} >
{ modalFormRef.current = formAPI; }} > 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('参数覆盖模板')}
{t( '命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。', )}
{paramTemplatePreviewMeta.tagLabel}
                      {paramTemplatePreviewMeta.preview}
                    
{t( '开启后,using_group 会参与 cache key(不同分组隔离)。', )} {t('开启后,规则名称会参与 cache key(不同规则隔离)。')} {t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')} {t('Key 来源')} {t( 'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。', )}
{t('常用上下文 Key(用于 context_*)')}:
{(CONTEXT_KEY_PRESETS || []).map((x) => ( {x.label} ))}
( updateKeySource( idx, isGjson ? { path: value } : { key: value }, ) } /> ); }, }, { title: t('操作'), width: 90, render: (_, __, idx) => (