/* 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) => ( {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} >
{ 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( '开启后,using_group 会参与 cache key(不同分组隔离)。', )} {t('开启后,规则名称会参与 cache key(不同规则隔离)。')} {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) => (