forked from boos/BoosAPI
Preserve the active worktree snapshot on dev for follow-on integration
The root checkout already contained a large in-progress proxy, transformer, and settings refactor. This commit captures that exact work on a dedicated local dev branch without disturbing the original dirty worktree, so the changes can be continued or split from dev instead of living only as uncommitted state. Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state Confidence: medium Scope-risk: broad Reversibility: clean Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication Tested: git diff --check; npm run typecheck; npm run repo:drift-check Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
This commit is contained in:
@@ -342,6 +342,7 @@ export type RuntimeRoutingWeightsPayload = {
|
||||
export type RuntimeSettingsPayload = {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
payloadRules?: Record<string, unknown> | null;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||
|
||||
+32
-7
@@ -6764,14 +6764,13 @@ textarea {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
contain: layout paint;
|
||||
will-change: transform, opacity, clip-path;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence {
|
||||
overflow: clip;
|
||||
contain: layout paint;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: grid-template-rows 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing {
|
||||
@@ -6779,18 +6778,44 @@ textarea {
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-open .route-card-detail-panel {
|
||||
animation: route-detail-panel-in 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: route-detail-panel-in 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.route-detail-panel-presence.is-closing .route-card-detail-panel {
|
||||
animation: route-detail-panel-out 0.2s cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-8px) scale(0.992);
|
||||
}
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1.006);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-detail-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0 round 16px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 0 100% 0 round 16px);
|
||||
transform: translateY(-6px) scale(0.994);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,30 @@ 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[]),
|
||||
) => {
|
||||
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 +725,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 +979,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 +1534,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}>
|
||||
|
||||
@@ -93,7 +93,7 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
||||
sourceRouteIds: [],
|
||||
advancedOpen: false,
|
||||
};
|
||||
const DESKTOP_DETAIL_COLLAPSE_MS = 180;
|
||||
const DESKTOP_DETAIL_COLLAPSE_MS = 200;
|
||||
|
||||
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
|
||||
if (value === 'round_robin') return '已切换为轮询策略';
|
||||
@@ -150,12 +150,10 @@ export function DesktopDetailPanelPresence({
|
||||
if (!shouldRender) return null;
|
||||
return (
|
||||
<div
|
||||
className={`route-detail-panel-presence anim-collapse route-panel-collapse ${isOpen ? 'is-open' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
|
||||
className={`route-detail-panel-presence ${isOpen ? 'is-open' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
|
||||
style={{ gridColumn: '1 / -1' }}
|
||||
>
|
||||
<div className="anim-collapse-inner">
|
||||
{children(isClosing)}
|
||||
</div>
|
||||
{children(isClosing)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -230,7 +230,7 @@ describe('TokenRoutes desktop detail panel', () => {
|
||||
&& String(node.props.className || '').includes('route-detail-panel-presence')
|
||||
));
|
||||
expect(detailPanelPresence).toHaveLength(1);
|
||||
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('anim-collapse');
|
||||
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('anim-collapse');
|
||||
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('is-open');
|
||||
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
|
||||
const detailPanelsWhileClosing = root.root.findAll((node) => (
|
||||
|
||||
Reference in New Issue
Block a user