[codex] add payload rules settings UI (#473)
* Make payload request overrides configurable from Settings The payload-rules engine already existed in the proxy path, but operators had no supported runtime UI to manage CPA-style overrides. This wires the setting through the runtime settings API, adds strict validation, and ships a low-friction visual editor with an advanced JSON fallback so common cases like Codex high reasoning can be configured without hand-editing backend state. Constraint: Must reuse the existing payload-rules execution path and settings table without schema changes Constraint: Must fit the existing metapi Settings-page patterns rather than introducing a standalone config surface Rejected: JSON-only editor as the primary UX | too long and too easy to misconfigure for common cases Rejected: A brand-new backend rule format for the UI | would duplicate the existing payload-rules source of truth Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep visual-rule serialization and advanced JSON editing in sync before extending the rule model Tested: npm run typecheck Tested: npx vitest run --root . src/server/services/payloadRules.test.ts src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx Tested: npm run repo:drift-check Not-tested: Manual browser interaction smoke on the Settings page in this clean PR worktree * Prevent payload-rules saves from violating review expectations Code review surfaced two operator-facing edge cases in the first payload-rules PR: successful rule parsing could still be committed before a later validation error, and visual edits could silently overwrite unsaved advanced JSON. This stages payload-rules until the request finishes validating and adds an explicit confirmation gate before visual actions discard unsynced advanced edits. Constraint: Keep the fix narrow to the reviewed payload-rules feature without refactoring unrelated runtime-settings atomicity behavior Rejected: Silently skipping draft resync while keeping visual edits active | would leave save semantics ambiguous when advanced JSON stays dirty Confidence: high Scope-risk: narrow Reversibility: clean Directive: If the settings endpoint ever moves toward full request atomicity, re-check this staged payload-rules write path against the broader strategy Tested: npm run typecheck Tested: npx vitest run --root . src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx Tested: npm run repo:drift-check Not-tested: Manual browser smoke of the confirmation dialog text
This commit is contained in:
@@ -68,6 +68,13 @@ describe('settings and auth events', () => {
|
||||
(config as any).proxyFirstByteTimeoutSec = 0;
|
||||
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
|
||||
(config as any).disableCrossProtocolFallback = false;
|
||||
(config as any).payloadRules = {
|
||||
default: [],
|
||||
defaultRaw: [],
|
||||
override: [],
|
||||
overrideRaw: [],
|
||||
filter: [],
|
||||
};
|
||||
(config as any).telegramEnabled = false;
|
||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||
(config as any).telegramBotToken = '';
|
||||
@@ -219,6 +226,137 @@ describe('settings and auth events', () => {
|
||||
expect(savedMaxBodyBytes?.value).toBe(JSON.stringify(131072));
|
||||
});
|
||||
|
||||
it('persists payload rules and returns the normalized runtime value', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
payloadRules: {
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
'override-raw': [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{"type":"json_schema"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json() as {
|
||||
payloadRules?: {
|
||||
default?: unknown[];
|
||||
defaultRaw?: unknown[];
|
||||
override?: Array<{ params?: Record<string, unknown> }>;
|
||||
overrideRaw?: Array<{ params?: Record<string, unknown> }>;
|
||||
filter?: unknown[];
|
||||
};
|
||||
};
|
||||
expect(payload.payloadRules).toEqual({
|
||||
default: [],
|
||||
defaultRaw: [],
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
overrideRaw: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{"type":"json_schema"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [],
|
||||
});
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get();
|
||||
expect(saved).toBeTruthy();
|
||||
expect(JSON.parse(String(saved?.value))).toEqual(payload.payloadRules);
|
||||
expect(config.payloadRules).toEqual(payload.payloadRules);
|
||||
|
||||
const events = await db.select().from(schema.events).all();
|
||||
expect(events.some((event) => (event.message || '').includes('Payload 规则'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid payload raw rules with a clear message', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
payloadRules: {
|
||||
'override-raw': [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{broken-json',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON',
|
||||
});
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get();
|
||||
expect(saved).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not persist payload rules when a later runtime setting fails validation', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
payloadRules: {
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
modelAvailabilityProbeEnabled: 'invalid-boolean',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: '批量测活开关格式无效:需要 boolean',
|
||||
});
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get();
|
||||
expect(saved).toBeFalsy();
|
||||
expect(config.payloadRules).toEqual({
|
||||
default: [],
|
||||
defaultRaw: [],
|
||||
override: [],
|
||||
overrideRaw: [],
|
||||
filter: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns current recognized admin IP in runtime settings response', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
|
||||
@@ -44,12 +44,14 @@ import {
|
||||
startModelAvailabilityProbeScheduler,
|
||||
stopModelAvailabilityProbeScheduler,
|
||||
} from '../../services/modelAvailabilityProbeService.js';
|
||||
import { parsePayloadRulesConfigInput } from '../../services/payloadRules.js';
|
||||
|
||||
type RoutingWeights = typeof config.routingWeights;
|
||||
|
||||
interface RuntimeSettingsBody {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
payloadRules?: unknown;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||
@@ -759,6 +761,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
currentAdminIp,
|
||||
serverTimeZone: getResolvedTimeZone(),
|
||||
systemProxyUrl: config.systemProxyUrl,
|
||||
payloadRules: config.payloadRules,
|
||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
@@ -902,6 +905,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const body = parsedBody.data as RuntimeSettingsBody;
|
||||
const changedLabels: string[] = [];
|
||||
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||
let pendingPayloadRules: typeof config.payloadRules | undefined;
|
||||
|
||||
const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined;
|
||||
const nextWebhookUrl = body.webhookUrl !== undefined
|
||||
@@ -1132,6 +1136,23 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
invalidateSiteProxyCache();
|
||||
}
|
||||
|
||||
if (body.payloadRules !== undefined) {
|
||||
const parsedPayloadRules = parsePayloadRulesConfigInput(body.payloadRules);
|
||||
if (!parsedPayloadRules.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedPayloadRules.message,
|
||||
});
|
||||
}
|
||||
|
||||
const previousRules = JSON.stringify(config.payloadRules);
|
||||
const nextRules = JSON.stringify(parsedPayloadRules.normalized);
|
||||
if (previousRules !== nextRules) {
|
||||
changedLabels.push('Payload 规则');
|
||||
}
|
||||
pendingPayloadRules = parsedPayloadRules.normalized;
|
||||
}
|
||||
|
||||
if (body.modelAvailabilityProbeEnabled !== undefined) {
|
||||
let nextValue = false;
|
||||
try {
|
||||
@@ -1697,6 +1718,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upsertSetting('token_router_failure_cooldown_max_sec', normalized);
|
||||
}
|
||||
|
||||
if (pendingPayloadRules !== undefined) {
|
||||
config.payloadRules = pendingPayloadRules;
|
||||
await upsertSetting('payload_rules', pendingPayloadRules);
|
||||
}
|
||||
|
||||
if (changedLabels.length > 0) {
|
||||
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
||||
if (changedLabels.length === 1) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createEmptyPayloadRulesConfig, parsePayloadRulesConfigInput } from './payloadRules.js';
|
||||
|
||||
describe('parsePayloadRulesConfigInput', () => {
|
||||
it('accepts CPA-style dashed raw keys and normalizes them to camelCase sections', () => {
|
||||
const result = parsePayloadRulesConfigInput({
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
'override-raw': [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{"type":"json_schema"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: ['safety_identifier'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.normalized).toEqual({
|
||||
...createEmptyPayloadRulesConfig(),
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
overrideRaw: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{"type":"json_schema"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: ['safety_identifier'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid raw JSON fragments', () => {
|
||||
const result = parsePayloadRulesConfigInput({
|
||||
'override-raw': [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{invalid-json',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown sections so the settings UI cannot silently save ignored data', () => {
|
||||
const result = parsePayloadRulesConfigInput({
|
||||
override: [],
|
||||
unexpected: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Payload 规则包含未知分组:unexpected',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,18 @@ export type PayloadRulesConfig = {
|
||||
filter: PayloadFilterRule[];
|
||||
};
|
||||
|
||||
type PayloadRulesParseSuccess = {
|
||||
success: true;
|
||||
normalized: PayloadRulesConfig;
|
||||
};
|
||||
|
||||
type PayloadRulesParseFailure = {
|
||||
success: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PayloadRulesParseResult = PayloadRulesParseSuccess | PayloadRulesParseFailure;
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -230,6 +242,165 @@ export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig
|
||||
};
|
||||
}
|
||||
|
||||
const PAYLOAD_RULE_SECTION_LABELS = {
|
||||
default: 'default',
|
||||
defaultRaw: 'default-raw',
|
||||
override: 'override',
|
||||
overrideRaw: 'override-raw',
|
||||
filter: 'filter',
|
||||
} as const;
|
||||
|
||||
const PAYLOAD_RULE_ALLOWED_KEYS = new Set([
|
||||
'default',
|
||||
'defaultRaw',
|
||||
'default-raw',
|
||||
'override',
|
||||
'overrideRaw',
|
||||
'override-raw',
|
||||
'filter',
|
||||
]);
|
||||
|
||||
function getPayloadRulesSectionValue(value: Record<string, unknown>, key: keyof typeof PAYLOAD_RULE_SECTION_LABELS): unknown {
|
||||
if (key === 'defaultRaw') return value.defaultRaw ?? value['default-raw'];
|
||||
if (key === 'overrideRaw') return value.overrideRaw ?? value['override-raw'];
|
||||
return value[key];
|
||||
}
|
||||
|
||||
function hasConflictingPayloadRuleSectionKeys(value: Record<string, unknown>, camelKey: 'defaultRaw' | 'overrideRaw', dashedKey: 'default-raw' | 'override-raw'): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, camelKey) && Object.prototype.hasOwnProperty.call(value, dashedKey);
|
||||
}
|
||||
|
||||
function validatePayloadRuleModelsInput(value: unknown, section: string, ruleIndex: number): string | null {
|
||||
if (!Array.isArray(value) || value.length <= 0) {
|
||||
return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models 必须是非空数组`;
|
||||
}
|
||||
|
||||
for (let modelIndex = 0; modelIndex < value.length; modelIndex += 1) {
|
||||
const model = value[modelIndex];
|
||||
if (!isRecord(model)) {
|
||||
return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}] 必须是对象`;
|
||||
}
|
||||
if (!asTrimmedString(model.name)) {
|
||||
return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].name 不能为空`;
|
||||
}
|
||||
if (model.protocol !== undefined && typeof model.protocol !== 'string') {
|
||||
return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].protocol 必须是字符串`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePayloadValueRuleSectionInput(value: unknown, section: string, raw: boolean): string | null {
|
||||
if (value === undefined) return null;
|
||||
if (!Array.isArray(value)) {
|
||||
return `Payload 规则 ${section} 必须是数组`;
|
||||
}
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = value[index];
|
||||
if (!isRecord(item)) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`;
|
||||
}
|
||||
|
||||
const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1);
|
||||
if (modelsError) return modelsError;
|
||||
|
||||
if (!isRecord(item.params) || Object.keys(item.params).length <= 0) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空对象`;
|
||||
}
|
||||
|
||||
for (const [path, rawValue] of Object.entries(item.params)) {
|
||||
if (!asTrimmedString(path)) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条包含空参数路径`;
|
||||
}
|
||||
if (!raw) continue;
|
||||
if (typeof rawValue !== 'string') continue;
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不能为空字符串`;
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
} catch {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不是合法 JSON`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePayloadFilterRuleSectionInput(value: unknown, section: string): string | null {
|
||||
if (value === undefined) return null;
|
||||
if (!Array.isArray(value)) {
|
||||
return `Payload 规则 ${section} 必须是数组`;
|
||||
}
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = value[index];
|
||||
if (!isRecord(item)) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`;
|
||||
}
|
||||
|
||||
const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1);
|
||||
if (modelsError) return modelsError;
|
||||
|
||||
if (!Array.isArray(item.params) || item.params.length <= 0) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空字符串数组`;
|
||||
}
|
||||
|
||||
for (let pathIndex = 0; pathIndex < item.params.length; pathIndex += 1) {
|
||||
if (!asTrimmedString(item.params[pathIndex])) {
|
||||
return `Payload 规则 ${section} 第 ${index + 1} 条的 params[${pathIndex + 1}] 不能为空`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePayloadRulesConfigInput(value: unknown): PayloadRulesParseResult {
|
||||
if (value == null) {
|
||||
return { success: true, normalized: createEmptyPayloadRulesConfig() };
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, message: 'Payload 规则必须是 JSON 对象' };
|
||||
}
|
||||
|
||||
if (hasConflictingPayloadRuleSectionKeys(value, 'defaultRaw', 'default-raw')) {
|
||||
return { success: false, message: 'Payload 规则不能同时包含 defaultRaw 和 default-raw' };
|
||||
}
|
||||
if (hasConflictingPayloadRuleSectionKeys(value, 'overrideRaw', 'override-raw')) {
|
||||
return { success: false, message: 'Payload 规则不能同时包含 overrideRaw 和 override-raw' };
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter((key) => !PAYLOAD_RULE_ALLOWED_KEYS.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Payload 规则包含未知分组:${unknownKeys.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const valueSectionError = validatePayloadValueRuleSectionInput(value.default, PAYLOAD_RULE_SECTION_LABELS.default, false)
|
||||
?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'defaultRaw'), PAYLOAD_RULE_SECTION_LABELS.defaultRaw, true)
|
||||
?? validatePayloadValueRuleSectionInput(value.override, PAYLOAD_RULE_SECTION_LABELS.override, false)
|
||||
?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'overrideRaw'), PAYLOAD_RULE_SECTION_LABELS.overrideRaw, true)
|
||||
?? validatePayloadFilterRuleSectionInput(value.filter, PAYLOAD_RULE_SECTION_LABELS.filter);
|
||||
if (valueSectionError) {
|
||||
return {
|
||||
success: false,
|
||||
message: valueSectionError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
normalized: normalizePayloadRulesConfig(value),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPayloadRules(input: {
|
||||
rules: PayloadRulesConfig;
|
||||
payload: Record<string, unknown>;
|
||||
|
||||
@@ -362,6 +362,7 @@ export type RuntimeRoutingWeightsPayload = {
|
||||
export type RuntimeSettingsPayload = {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
payloadRules?: Record<string, unknown> | null;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||
|
||||
@@ -8,6 +8,16 @@ import ModernSelect from '../components/ModernSelect.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
|
||||
import {
|
||||
createCodexDefaultHighReasoningVisualPreset,
|
||||
createVisualPayloadRule,
|
||||
isVisualPayloadRuleBlank,
|
||||
payloadRulesToVisualRules,
|
||||
type PayloadRuleAction,
|
||||
type VisualPayloadRule,
|
||||
type VisualPayloadRuleValueMode,
|
||||
visualRulesToPayloadRules,
|
||||
} from './settings/payloadRulesVisual.js';
|
||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
@@ -44,6 +54,8 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
|
||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
||||
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
|
||||
type PayloadRulesEditorSectionKey = PayloadRuleAction;
|
||||
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
|
||||
|
||||
type RuntimeSettings = {
|
||||
checkinCron: string;
|
||||
@@ -118,6 +130,154 @@ type ShorthandConnection = {
|
||||
database: string;
|
||||
};
|
||||
|
||||
const PAYLOAD_RULES_EDITOR_SECTIONS = [
|
||||
{
|
||||
key: 'default',
|
||||
title: 'default',
|
||||
description: '字段缺失时才注入,适合补默认参数。',
|
||||
placeholder: `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": {
|
||||
"reasoning.effort": "high"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
key: 'default-raw',
|
||||
title: 'default-raw',
|
||||
description: '字段缺失时注入原始 JSON,适合 schema、复杂对象等值。',
|
||||
placeholder: `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": {
|
||||
"response_format": "{\"type\":\"json_schema\"}"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
key: 'override',
|
||||
title: 'override',
|
||||
description: '无论原请求是否已有该字段,都强制覆盖。',
|
||||
placeholder: `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": {
|
||||
"text.verbosity": "low"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
key: 'override-raw',
|
||||
title: 'override-raw',
|
||||
description: '无论原请求是否已有该字段,都强制覆盖为原始 JSON。',
|
||||
placeholder: `[
|
||||
{
|
||||
"models": [{ "name": "gemini-*", "protocol": "gemini" }],
|
||||
"params": {
|
||||
"generationConfig.responseJsonSchema": "{\"type\":\"object\"}"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
key: 'filter',
|
||||
title: 'filter',
|
||||
description: '删除匹配请求中的字段。',
|
||||
placeholder: `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": ["safety_identifier"]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: PayloadRulesEditorSectionKey;
|
||||
title: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
}>;
|
||||
|
||||
const PAYLOAD_RULE_ACTION_OPTIONS: Array<{ value: PayloadRuleAction; label: string }> = [
|
||||
{ value: 'default', label: '默认注入' },
|
||||
{ value: 'default-raw', label: '默认注入 JSON' },
|
||||
{ value: 'override', label: '强制覆盖' },
|
||||
{ value: 'override-raw', label: '强制覆盖 JSON' },
|
||||
{ value: 'filter', label: '删除字段' },
|
||||
];
|
||||
|
||||
const PAYLOAD_RULE_PROTOCOL_OPTIONS = [
|
||||
{ value: '', label: '全部协议' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
];
|
||||
|
||||
const PAYLOAD_RULE_VALUE_MODE_OPTIONS: Array<{ value: VisualPayloadRuleValueMode; label: string }> = [
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
];
|
||||
|
||||
function createEmptyPayloadRuleDrafts(): PayloadRulesEditorDrafts {
|
||||
return {
|
||||
default: '',
|
||||
'default-raw': '',
|
||||
override: '',
|
||||
'override-raw': '',
|
||||
filter: '',
|
||||
};
|
||||
}
|
||||
|
||||
function formatPayloadRuleSectionForEditor(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (Array.isArray(value) && value.length <= 0) return '';
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function normalizePayloadRulesForEditor(value: unknown): PayloadRulesEditorDrafts {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return createEmptyPayloadRuleDrafts();
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
default: formatPayloadRuleSectionForEditor(record.default),
|
||||
'default-raw': formatPayloadRuleSectionForEditor(record.defaultRaw ?? record['default-raw']),
|
||||
override: formatPayloadRuleSectionForEditor(record.override),
|
||||
'override-raw': formatPayloadRuleSectionForEditor(record.overrideRaw ?? record['override-raw']),
|
||||
filter: formatPayloadRuleSectionForEditor(record.filter),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePayloadRulesFromDrafts(
|
||||
drafts: PayloadRulesEditorDrafts,
|
||||
): { success: true; value: Record<string, unknown> } | { success: false; message: string } {
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const section of PAYLOAD_RULES_EDITOR_SECTIONS) {
|
||||
const raw = drafts[section.key].trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
next[section.key] = JSON.parse(raw);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Payload 规则 ${section.title} 不是合法 JSON:${error?.message || '解析失败'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: next,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultWeights: RoutingWeights = {
|
||||
baseWeightFactor: 0.5,
|
||||
valueScoreFactor: 0.5,
|
||||
@@ -225,6 +385,11 @@ export default function Settings() {
|
||||
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
||||
const [payloadVisualRules, setPayloadVisualRules] = useState<VisualPayloadRule[]>([]);
|
||||
const [payloadRuleDrafts, setPayloadRuleDrafts] = useState<PayloadRulesEditorDrafts>(createEmptyPayloadRuleDrafts());
|
||||
const [payloadAdvancedDirty, setPayloadAdvancedDirty] = useState(false);
|
||||
const [savingPayloadRules, setSavingPayloadRules] = useState(false);
|
||||
const [showPayloadRulesEditor, setShowPayloadRulesEditor] = useState(false);
|
||||
const [savingRouting, setSavingRouting] = useState(false);
|
||||
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
||||
const [allBrandNames, setAllBrandNames] = useState<string[] | null>(null);
|
||||
@@ -272,6 +437,11 @@ export default function Settings() {
|
||||
[runtime.routingWeights],
|
||||
);
|
||||
|
||||
const configuredPayloadRuleCount = useMemo(
|
||||
() => payloadVisualRules.filter((rule) => !isVisualPayloadRuleBlank(rule)).length,
|
||||
[payloadVisualRules],
|
||||
);
|
||||
|
||||
const generatedConnectionString = useMemo(() => (
|
||||
buildShorthandConnectionString(migrationDialect, shorthandConnection)
|
||||
), [migrationDialect, shorthandConnection]);
|
||||
@@ -465,6 +635,38 @@ export default function Settings() {
|
||||
? '已启用'
|
||||
: '已关闭';
|
||||
|
||||
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
|
||||
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
|
||||
setPayloadAdvancedDirty(false);
|
||||
};
|
||||
|
||||
const syncPayloadVisualRulesFromObject = (value: unknown) => {
|
||||
setPayloadVisualRules(payloadRulesToVisualRules(value));
|
||||
};
|
||||
|
||||
const applyVisualPayloadRules = (
|
||||
nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]),
|
||||
) => {
|
||||
if (
|
||||
payloadAdvancedDirty
|
||||
&& typeof globalThis.confirm === 'function'
|
||||
&& !globalThis.confirm('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPayloadVisualRules((currentRules) => {
|
||||
const nextRules = typeof nextRulesOrUpdater === 'function'
|
||||
? nextRulesOrUpdater(currentRules)
|
||||
: nextRulesOrUpdater;
|
||||
const serialized = visualRulesToPayloadRules(nextRules);
|
||||
if (serialized.success) {
|
||||
syncPayloadRuleDraftsFromObject(serialized.value);
|
||||
}
|
||||
return nextRules;
|
||||
});
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -531,6 +733,8 @@ export default function Settings() {
|
||||
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
|
||||
: '',
|
||||
);
|
||||
syncPayloadRuleDraftsFromObject(runtimeInfo.payloadRules);
|
||||
syncPayloadVisualRulesFromObject(runtimeInfo.payloadRules);
|
||||
setAdminIpAllowlistText(
|
||||
Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||
? runtimeInfo.adminIpAllowlist.join('\n')
|
||||
@@ -783,6 +987,80 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const savePayloadRules = async () => {
|
||||
const nextPayloadRules = payloadAdvancedDirty
|
||||
? parsePayloadRulesFromDrafts(payloadRuleDrafts)
|
||||
: visualRulesToPayloadRules(payloadVisualRules);
|
||||
if (!nextPayloadRules.success) {
|
||||
toast.error(nextPayloadRules.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingPayloadRules(true);
|
||||
try {
|
||||
const res = await api.updateRuntimeSettings({
|
||||
payloadRules: nextPayloadRules.value,
|
||||
});
|
||||
syncPayloadRuleDraftsFromObject(res?.payloadRules);
|
||||
syncPayloadVisualRulesFromObject(res?.payloadRules);
|
||||
toast.success('Payload 规则已保存');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存 Payload 规则失败');
|
||||
} finally {
|
||||
setSavingPayloadRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCodexDefaultHighReasoningPreset = () => {
|
||||
applyVisualPayloadRules((currentRules) => [
|
||||
...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)),
|
||||
...createCodexDefaultHighReasoningVisualPreset(),
|
||||
]);
|
||||
setShowPayloadRulesEditor(true);
|
||||
toast.success('已填入 Codex 默认高推理预设');
|
||||
};
|
||||
|
||||
const addPayloadVisualRule = () => {
|
||||
applyVisualPayloadRules((currentRules) => [
|
||||
...currentRules,
|
||||
createVisualPayloadRule(),
|
||||
]);
|
||||
};
|
||||
|
||||
const updatePayloadVisualRule = (ruleId: string, patch: Partial<VisualPayloadRule>) => {
|
||||
applyVisualPayloadRules((currentRules) => currentRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const nextAction = (patch.action ?? rule.action) as PayloadRuleAction;
|
||||
const nextValueMode = patch.valueMode ?? (
|
||||
nextAction === 'default-raw' || nextAction === 'override-raw'
|
||||
? 'json'
|
||||
: rule.valueMode
|
||||
);
|
||||
return {
|
||||
...rule,
|
||||
...patch,
|
||||
action: nextAction,
|
||||
valueMode: nextAction === 'filter' ? 'text' : nextValueMode,
|
||||
value: nextAction === 'filter' ? '' : (patch.value ?? rule.value),
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const removePayloadVisualRule = (ruleId: string) => {
|
||||
applyVisualPayloadRules((currentRules) => currentRules.filter((rule) => rule.id !== ruleId));
|
||||
};
|
||||
|
||||
const syncVisualRulesFromAdvancedJson = () => {
|
||||
const parsedPayloadRules = parsePayloadRulesFromDrafts(payloadRuleDrafts);
|
||||
if (!parsedPayloadRules.success) {
|
||||
toast.error(parsedPayloadRules.message);
|
||||
return;
|
||||
}
|
||||
syncPayloadVisualRulesFromObject(parsedPayloadRules.value);
|
||||
setPayloadAdvancedDirty(false);
|
||||
toast.success('已将高级 JSON 同步到可视化规则');
|
||||
};
|
||||
|
||||
const saveRouting = async () => {
|
||||
setSavingRouting(true);
|
||||
try {
|
||||
@@ -1264,6 +1542,256 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules">
|
||||
<div style={settingsModernHeaderStyle}>
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
<div style={settingsModernTitleStyle}>Payload 规则</div>
|
||||
<div style={settingsModernDescriptionStyle}>
|
||||
对匹配模型的上游请求做默认注入、强制覆盖或字段过滤。规则结构参考 CPA 的 payload 配置,常见场景可直接注入
|
||||
{' '}
|
||||
<code style={{ fontFamily: 'var(--font-mono)' }}>reasoning.effort</code>
|
||||
{' '}
|
||||
之类的参数。
|
||||
</div>
|
||||
</div>
|
||||
<div style={settingsModernPillRowStyle}>
|
||||
<span style={getSettingsPillStyle(configuredPayloadRuleCount > 0 ? 'primary' : 'neutral')}>
|
||||
{configuredPayloadRuleCount > 0 ? `已配置 ${configuredPayloadRuleCount} 条` : '未配置'}
|
||||
</span>
|
||||
<span style={getSettingsPillStyle(payloadAdvancedDirty ? 'warning' : 'neutral')}>
|
||||
{payloadAdvancedDirty ? '高级 JSON 待同步/保存' : '保存后立即生效'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={settingsModernFieldCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'grid', gap: 6, minWidth: 0 }}>
|
||||
<div style={settingsModernFieldLabelStyle}>常用预设</div>
|
||||
<div style={settingsModernFieldHintStyle}>
|
||||
先用预设快速填充,再通过下面的可视化规则编辑器细调。复杂场景仍可回退到高级 JSON。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={applyCodexDefaultHighReasoningPreset}
|
||||
>
|
||||
Codex 默认高推理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={addPayloadVisualRule}
|
||||
>
|
||||
新增规则
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => setShowPayloadRulesEditor((prev) => !prev)}
|
||||
>
|
||||
{showPayloadRulesEditor ? '收起高级 JSON 编辑' : '展开高级 JSON 编辑'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{payloadVisualRules.length <= 0 ? (
|
||||
<div style={settingsModernFieldCardStyle}>
|
||||
<div style={settingsModernFieldLabelStyle}>还没有可视化规则</div>
|
||||
<div style={settingsModernFieldHintStyle}>
|
||||
可以先点上面的预设,也可以直接新增一条规则:选择动作、协议、模型匹配、字段路径和值即可。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{payloadVisualRules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
style={settingsModernFieldCardStyle}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={settingsModernFieldLabelStyle}>规则 {index + 1}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', color: 'var(--color-danger)' }}
|
||||
onClick={() => removePayloadVisualRule(rule.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveFormGrid columns={2}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>动作</div>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
data-testid={`payload-rule-action-${index + 1}`}
|
||||
value={rule.action}
|
||||
onChange={(value) => updatePayloadVisualRule(rule.id, { action: value as PayloadRuleAction })}
|
||||
options={PAYLOAD_RULE_ACTION_OPTIONS}
|
||||
placeholder="选择动作"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>协议</div>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
data-testid={`payload-rule-protocol-${index + 1}`}
|
||||
value={rule.protocol}
|
||||
onChange={(value) => updatePayloadVisualRule(rule.id, { protocol: String(value || '') })}
|
||||
options={PAYLOAD_RULE_PROTOCOL_OPTIONS}
|
||||
placeholder="全部协议"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>模型匹配</div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Payload 规则可视化模型 ${index + 1}`}
|
||||
value={rule.modelPattern}
|
||||
onChange={(e) => updatePayloadVisualRule(rule.id, { modelPattern: e.target.value })}
|
||||
placeholder="例如 gpt-*"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>字段路径</div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Payload 规则可视化路径 ${index + 1}`}
|
||||
value={rule.path}
|
||||
onChange={(e) => updatePayloadVisualRule(rule.id, { path: e.target.value })}
|
||||
placeholder="例如 reasoning.effort"
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveFormGrid>
|
||||
{rule.action === 'filter' ? (
|
||||
<div style={settingsModernFieldHintStyle}>
|
||||
删除字段规则不需要填写值,命中后会从请求中移除这条路径。
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{(rule.action === 'default' || rule.action === 'override') && (
|
||||
<div style={{ width: isMobile ? '100%' : 180 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>值类型</div>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
data-testid={`payload-rule-value-mode-${index + 1}`}
|
||||
value={rule.valueMode}
|
||||
onChange={(value) => updatePayloadVisualRule(rule.id, {
|
||||
valueMode: value as VisualPayloadRuleValueMode,
|
||||
value: value === 'json' && rule.valueMode !== 'json'
|
||||
? (rule.value ? JSON.stringify(rule.value) : '')
|
||||
: rule.value,
|
||||
})}
|
||||
options={PAYLOAD_RULE_VALUE_MODE_OPTIONS}
|
||||
placeholder="值类型"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{rule.action === 'default-raw' || rule.action === 'override-raw'
|
||||
? '原始 JSON 值'
|
||||
: (rule.valueMode === 'json' ? 'JSON 值' : '文本值')}
|
||||
</div>
|
||||
{(rule.action === 'default-raw' || rule.action === 'override-raw' || rule.valueMode === 'json') ? (
|
||||
<textarea
|
||||
aria-label={`Payload 规则可视化值 ${index + 1}`}
|
||||
value={rule.value}
|
||||
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
|
||||
placeholder={rule.action === 'default-raw' || rule.action === 'override-raw'
|
||||
? '{"type":"json_schema"}'
|
||||
: '{"effort":"high"}'}
|
||||
rows={3}
|
||||
style={{
|
||||
...inputStyle,
|
||||
minHeight: 88,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Payload 规则可视化值 ${index + 1}`}
|
||||
value={rule.value}
|
||||
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
|
||||
placeholder="例如 high"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`anim-collapse ${showPayloadRulesEditor ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||
<div style={settingsModernFieldCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<div style={settingsModernFieldLabelStyle}>高级 JSON 编辑</div>
|
||||
<div style={settingsModernFieldHintStyle}>
|
||||
适合直接粘贴 CPA 风格规则。手动改完后,可点击“同步到可视化规则”回到上面的低门槛编辑器。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={syncVisualRulesFromAdvancedJson}
|
||||
>
|
||||
同步到可视化规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveFormGrid columns={2}>
|
||||
{PAYLOAD_RULES_EDITOR_SECTIONS.map((section) => (
|
||||
<div key={section.key} style={settingsModernFieldCardStyle}>
|
||||
<div style={settingsModernFieldLabelStyle}>{section.title}</div>
|
||||
<div style={settingsModernFieldHintStyle}>{section.description}</div>
|
||||
<textarea
|
||||
aria-label={`Payload 规则 ${section.key}`}
|
||||
value={payloadRuleDrafts[section.key]}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setPayloadRuleDrafts((prev) => ({
|
||||
...prev,
|
||||
[section.key]: nextValue,
|
||||
}));
|
||||
setPayloadAdvancedDirty(true);
|
||||
}}
|
||||
placeholder={section.placeholder}
|
||||
rows={6}
|
||||
style={{
|
||||
...inputStyle,
|
||||
minHeight: 144,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveFormGrid>
|
||||
</div>
|
||||
</div>
|
||||
<div style={settingsModernActionsStyle}>
|
||||
<button onClick={savePayloadRules} disabled={savingPayloadRules} className="btn btn-primary">
|
||||
{savingPayloadRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存 Payload 规则'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
|
||||
<div style={settingsModernHeaderStyle}>
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Settings from './Settings.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: () => null,
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Settings payload rules', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 6,
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
proxyFirstByteTimeoutSec: 0,
|
||||
routingWeights: {},
|
||||
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
payloadRules: {
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.updateRuntimeSettings.mockResolvedValue({
|
||||
success: true,
|
||||
payloadRules: {
|
||||
default: [],
|
||||
defaultRaw: [],
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
overrideRaw: [],
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads saved payload rules into the editor', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const overrideTextarea = root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||
));
|
||||
|
||||
expect(String(overrideTextarea.props.value)).toContain('"reasoning.effort": "high"');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('fills the default section when applying the Codex default high-reasoning preset', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const presetButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === 'Codex 默认高推理'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
presetButton.props.onClick();
|
||||
});
|
||||
|
||||
const defaultTextarea = root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 default'
|
||||
));
|
||||
|
||||
expect(String(defaultTextarea.props.value)).toContain('"reasoning.effort": "high"');
|
||||
expect(String(defaultTextarea.props.value)).toContain('"protocol": "codex"');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('saves parsed payload rules through updateRuntimeSettings', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const overrideRawTextarea = root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 override-raw'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
overrideRawTextarea.props.onChange({
|
||||
target: {
|
||||
value: `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": {
|
||||
"response_format": "{\\"type\\":\\"json_schema\\"}"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存 Payload 规则'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||
payloadRules: {
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
'override-raw': [
|
||||
{
|
||||
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||
params: {
|
||||
response_format: '{"type":"json_schema"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('saves a rule created from the visual builder', async () => {
|
||||
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 6,
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
proxyFirstByteTimeoutSec: 0,
|
||||
routingWeights: {},
|
||||
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
payloadRules: {},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '新增规则'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
|
||||
const modelInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
|
||||
));
|
||||
const pathInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === 'Payload 规则可视化路径 1'
|
||||
));
|
||||
const valueInput = root.root.find((node) => (
|
||||
node.props['aria-label'] === 'Payload 规则可视化值 1'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
modelInput.props.onChange({ target: { value: 'gpt-*' } });
|
||||
pathInput.props.onChange({ target: { value: 'reasoning.effort' } });
|
||||
valueInput.props.onChange({ target: { value: 'high' } });
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存 Payload 规则'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||
payloadRules: {
|
||||
default: [
|
||||
{
|
||||
models: [{ name: 'gpt-*' }],
|
||||
params: {
|
||||
'reasoning.effort': 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
'default-raw': [],
|
||||
override: [],
|
||||
'override-raw': [],
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks save when a payload-rule section contains invalid JSON', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const filterTextarea = root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 filter'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
filterTextarea.props.onChange({ target: { value: '[' } });
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '保存 Payload 规则'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRuntimeSettings).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('asks before discarding unsynced advanced JSON when switching back to visual edits', async () => {
|
||||
const confirmMock = vi.fn().mockReturnValue(false);
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
|
||||
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||
checkinCron: '0 8 * * *',
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 6,
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
logCleanupCron: '15 4 * * *',
|
||||
logCleanupUsageLogsEnabled: true,
|
||||
logCleanupProgramLogsEnabled: true,
|
||||
logCleanupRetentionDays: 14,
|
||||
routingFallbackUnitCost: 1,
|
||||
proxyFirstByteTimeoutSec: 0,
|
||||
routingWeights: {},
|
||||
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
payloadRules: {},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const overrideTextarea = root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||
));
|
||||
|
||||
const advancedDraft = `[
|
||||
{
|
||||
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||
"params": {
|
||||
"text.verbosity": "low"
|
||||
}
|
||||
}
|
||||
]`;
|
||||
|
||||
await act(async () => {
|
||||
overrideTextarea.props.onChange({ target: { value: advancedDraft } });
|
||||
});
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '新增规则'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledWith('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?');
|
||||
expect(root.root.findAll((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
|
||||
))).toHaveLength(0);
|
||||
expect(String(root.root.find((node) => (
|
||||
node.type === 'textarea'
|
||||
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||
)).props.value)).toBe(advancedDraft);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
export type PayloadRuleAction = 'default' | 'default-raw' | 'override' | 'override-raw' | 'filter';
|
||||
export type VisualPayloadRuleValueMode = 'text' | 'json';
|
||||
|
||||
export type VisualPayloadRule = {
|
||||
id: string;
|
||||
action: PayloadRuleAction;
|
||||
modelPattern: string;
|
||||
protocol: string;
|
||||
path: string;
|
||||
value: string;
|
||||
valueMode: VisualPayloadRuleValueMode;
|
||||
};
|
||||
|
||||
let payloadRuleIdCounter = 0;
|
||||
|
||||
function nextPayloadRuleId(): string {
|
||||
payloadRuleIdCounter += 1;
|
||||
return `payload-rule-${payloadRuleIdCounter}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function formatJsonValue(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formatRawValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : formatJsonValue(value);
|
||||
}
|
||||
|
||||
export function createVisualPayloadRule(input: Partial<VisualPayloadRule> = {}): VisualPayloadRule {
|
||||
return {
|
||||
id: input.id || nextPayloadRuleId(),
|
||||
action: input.action || 'default',
|
||||
modelPattern: input.modelPattern || '',
|
||||
protocol: input.protocol || '',
|
||||
path: input.path || '',
|
||||
value: input.value || '',
|
||||
valueMode: input.valueMode || 'text',
|
||||
};
|
||||
}
|
||||
|
||||
export function isVisualPayloadRuleBlank(rule: VisualPayloadRule): boolean {
|
||||
return !rule.modelPattern.trim()
|
||||
&& !rule.protocol.trim()
|
||||
&& !rule.path.trim()
|
||||
&& !rule.value.trim();
|
||||
}
|
||||
|
||||
export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule[] {
|
||||
return [
|
||||
createVisualPayloadRule({
|
||||
action: 'default',
|
||||
modelPattern: 'gpt-*',
|
||||
protocol: 'codex',
|
||||
path: 'reasoning.effort',
|
||||
value: 'high',
|
||||
valueMode: 'text',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
const rules: VisualPayloadRule[] = [];
|
||||
const appendValueRules = (action: Exclude<PayloadRuleAction, 'filter'>, input: unknown) => {
|
||||
if (!Array.isArray(input)) return;
|
||||
for (const entry of input) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const models = Array.isArray(entry.models) ? entry.models : [];
|
||||
const params = isRecord(entry.params) ? entry.params : null;
|
||||
if (!params) continue;
|
||||
for (const model of models) {
|
||||
if (!isRecord(model)) continue;
|
||||
const modelPattern = asTrimmedString(model.name);
|
||||
if (!modelPattern) continue;
|
||||
const protocol = asTrimmedString(model.protocol);
|
||||
for (const [path, rawValue] of Object.entries(params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
if (action === 'default-raw' || action === 'override-raw') {
|
||||
rules.push(createVisualPayloadRule({
|
||||
action,
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: formatRawValue(rawValue),
|
||||
valueMode: 'json',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
rules.push(createVisualPayloadRule({
|
||||
action,
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: typeof rawValue === 'string' ? rawValue : formatJsonValue(rawValue),
|
||||
valueMode: typeof rawValue === 'string' ? 'text' : 'json',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const appendFilterRules = (input: unknown) => {
|
||||
if (!Array.isArray(input)) return;
|
||||
for (const entry of input) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const models = Array.isArray(entry.models) ? entry.models : [];
|
||||
const params = Array.isArray(entry.params) ? entry.params : [];
|
||||
for (const model of models) {
|
||||
if (!isRecord(model)) continue;
|
||||
const modelPattern = asTrimmedString(model.name);
|
||||
if (!modelPattern) continue;
|
||||
const protocol = asTrimmedString(model.protocol);
|
||||
for (const path of params) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
rules.push(createVisualPayloadRule({
|
||||
action: 'filter',
|
||||
modelPattern,
|
||||
protocol,
|
||||
path: normalizedPath,
|
||||
value: '',
|
||||
valueMode: 'text',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
appendValueRules('default', value.default);
|
||||
appendValueRules('default-raw', value.defaultRaw ?? value['default-raw']);
|
||||
appendValueRules('override', value.override);
|
||||
appendValueRules('override-raw', value.overrideRaw ?? value['override-raw']);
|
||||
appendFilterRules(value.filter);
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
type PayloadRulesSerializeSuccess = {
|
||||
success: true;
|
||||
value: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PayloadRulesSerializeFailure = {
|
||||
success: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type VisualPayloadRulesSerializeResult = PayloadRulesSerializeSuccess | PayloadRulesSerializeFailure;
|
||||
|
||||
type AggregatedValueRule = {
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AggregatedFilterRule = {
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: string[];
|
||||
};
|
||||
|
||||
function buildModelRule(rule: VisualPayloadRule): { name: string; protocol?: string } {
|
||||
const protocol = rule.protocol.trim();
|
||||
return protocol ? { name: rule.modelPattern.trim(), protocol } : { name: rule.modelPattern.trim() };
|
||||
}
|
||||
|
||||
function normalizeValueForRule(rule: VisualPayloadRule): { success: true; value: unknown } | PayloadRulesSerializeFailure {
|
||||
if (rule.action === 'filter') {
|
||||
return { success: true, value: undefined };
|
||||
}
|
||||
|
||||
if (rule.action === 'default-raw' || rule.action === 'override-raw') {
|
||||
const trimmed = rule.value.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, message: '原始 JSON 值不能为空' };
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
} catch (error: any) {
|
||||
return { success: false, message: `原始 JSON 无效:${error?.message || '解析失败'}` };
|
||||
}
|
||||
return { success: true, value: trimmed };
|
||||
}
|
||||
|
||||
if (rule.valueMode === 'json') {
|
||||
const trimmed = rule.value.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, message: 'JSON 值不能为空' };
|
||||
}
|
||||
try {
|
||||
return { success: true, value: JSON.parse(trimmed) };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: `JSON 值无效:${error?.message || '解析失败'}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!rule.value) {
|
||||
return { success: false, message: '文本值不能为空' };
|
||||
}
|
||||
return { success: true, value: rule.value };
|
||||
}
|
||||
|
||||
export function visualRulesToPayloadRules(rules: VisualPayloadRule[]): VisualPayloadRulesSerializeResult {
|
||||
const sections = {
|
||||
default: new Map<string, AggregatedValueRule>(),
|
||||
'default-raw': new Map<string, AggregatedValueRule>(),
|
||||
override: new Map<string, AggregatedValueRule>(),
|
||||
'override-raw': new Map<string, AggregatedValueRule>(),
|
||||
filter: new Map<string, AggregatedFilterRule>(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < rules.length; index += 1) {
|
||||
const rule = rules[index];
|
||||
if (isVisualPayloadRuleBlank(rule)) continue;
|
||||
|
||||
const modelPattern = rule.modelPattern.trim();
|
||||
if (!modelPattern) {
|
||||
return { success: false, message: `第 ${index + 1} 条规则缺少模型匹配` };
|
||||
}
|
||||
const path = rule.path.trim();
|
||||
if (!path) {
|
||||
return { success: false, message: `第 ${index + 1} 条规则缺少字段路径` };
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeValueForRule(rule);
|
||||
if (!normalizedValue.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: `第 ${index + 1} 条规则保存失败:${normalizedValue.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const groupKey = `${modelPattern}\u0000${rule.protocol.trim()}`;
|
||||
if (rule.action === 'filter') {
|
||||
const existing = sections.filter.get(groupKey) || {
|
||||
models: [buildModelRule(rule)],
|
||||
params: [],
|
||||
};
|
||||
if (!existing.params.includes(path)) {
|
||||
existing.params.push(path);
|
||||
}
|
||||
sections.filter.set(groupKey, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetSection = sections[rule.action];
|
||||
const existing = targetSection.get(groupKey) || {
|
||||
models: [buildModelRule(rule)],
|
||||
params: {},
|
||||
};
|
||||
existing.params[path] = normalizedValue.value;
|
||||
targetSection.set(groupKey, existing);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
default: Array.from(sections.default.values()),
|
||||
'default-raw': Array.from(sections['default-raw'].values()),
|
||||
override: Array.from(sections.override.values()),
|
||||
'override-raw': Array.from(sections['override-raw'].values()),
|
||||
filter: Array.from(sections.filter.values()),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user