|
|
@@ -0,0 +1,1139 @@
|
|
|
+/*
|
|
|
+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 [email protected]
|
|
|
+*/
|
|
|
+
|
|
|
+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: (
|
|
|
+ <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 = [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: (
|
|
|
+ <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('User-Agent include'),
|
|
|
+ dataIndex: 'user_agent_include',
|
|
|
+ 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) => {
|
|
|
+ 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,
|
|
|
+ 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 = (
|
|
|
+ <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 / Claude Code 模版')}
|
|
|
+ </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([]);
|
|
|
+ }}
|
|
|
+ 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} 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>
|
|
|
+ </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>
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|