| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
| |
| |
| refForm.current.setValue(KEY_RULES, jsonString || '[]'); |
| }; |
|
|
| const switchToJsonMode = () => { |
| |
| |
| 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; |
|
|
| |
| |
| 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('TTL(秒,0 表示默认)')} |
| 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)} |
| /> |
| </> |
| ); |
| } |
|
|