feat(i18n): add Home control plane settings and various enhancements

- Introduced Home control plane configuration options in English, Russian, Simplified Chinese, and Traditional Chinese locales.
- Added new fields for error log retention, usage statistics, and image generation settings in visual configuration.
- Enhanced payload model entries to include header management and condition matching capabilities.
- Updated default visual configuration values to accommodate new settings.
This commit is contained in:
LTbinglingfeng
2026-05-18 03:08:05 +08:00
parent cd1e7ff574
commit d6f5c45aa5
9 changed files with 1695 additions and 168 deletions
@@ -602,13 +602,41 @@
.payloadRuleModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
grid-template-columns: 1fr 160px auto auto;
gap: 8px;
align-items: center;
}
.payloadRuleModelRowProtocolFirst {
grid-template-columns: 160px 1fr auto;
grid-template-columns: 160px 1fr auto auto;
}
.payloadModelGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.payloadModelAdvanced {
display: flex;
flex-direction: column;
gap: 12px;
margin-left: 10px;
padding-left: 12px;
border-left: 2px solid var(--border-color);
}
.payloadAdvancedGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.payloadHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.payloadRuleParamRow {
@@ -660,6 +688,7 @@
@media (max-width: 900px) {
.payloadRuleModelRow,
.payloadRuleModelRowProtocolFirst,
.payloadHeaderRow,
.payloadRuleParamRow,
.payloadFilterModelRow {
grid-template-columns: minmax(0, 1fr);
+355 -8
View File
@@ -40,13 +40,7 @@ import {
} from './VisualConfigEditorBlocks';
import styles from './VisualConfigEditor.module.scss';
type VisualSectionId =
| 'server'
| 'auth'
| 'system'
| 'quota'
| 'streaming'
| 'payload';
type VisualSectionId = 'server' | 'auth' | 'system' | 'quota' | 'streaming' | 'payload';
type VisualSection = {
id: VisualSectionId;
@@ -175,6 +169,8 @@ export function VisualConfigEditor({
const isMobile = useMediaQuery('(max-width: 768px)');
const routingStrategyLabelId = useId();
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
const disableImageGenerationLabelId = useId();
const disableImageGenerationHintId = `${disableImageGenerationLabelId}-hint`;
const keepaliveInputId = useId();
const keepaliveHintId = `${keepaliveInputId}-hint`;
const keepaliveErrorId = `${keepaliveInputId}-error`;
@@ -195,10 +191,20 @@ export function VisualConfigEditor({
values.streaming.nonstreamKeepaliveInterval === '0';
const portError = getValidationMessage(t, validationErrors?.port);
const homePortError = getValidationMessage(t, validationErrors?.homePort);
const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb);
const errorLogsMaxFilesError = getValidationMessage(t, validationErrors?.errorLogsMaxFiles);
const redisUsageQueueRetentionError = getValidationMessage(
t,
validationErrors?.redisUsageQueueRetentionSeconds
);
const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry);
const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials);
const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
const authAutoRefreshWorkersError = getValidationMessage(
t,
validationErrors?.authAutoRefreshWorkers
);
const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']);
const bootstrapRetriesError = getValidationMessage(
t,
@@ -233,6 +239,23 @@ export function VisualConfigEditor({
(payloadFilterRules: PayloadFilterRule[]) => onChange({ payloadFilterRules }),
[onChange]
);
const disableImageGenerationOptions = useMemo(
() => [
{
value: 'false',
label: t('config_management.visual.sections.network.disable_image_generation_false'),
},
{
value: 'true',
label: t('config_management.visual.sections.network.disable_image_generation_true'),
},
{
value: 'chat',
label: t('config_management.visual.sections.network.disable_image_generation_chat'),
},
],
[t]
);
const countErrors = useCallback(
(fields: VisualConfigFieldPath[]) =>
@@ -246,7 +269,7 @@ export function VisualConfigEditor({
id: 'server',
title: t('config_management.visual.sections.server.title'),
icon: IconSettings,
errorCount: countErrors(['port']),
errorCount: countErrors(['port', 'homePort']),
},
{
id: 'auth',
@@ -259,10 +282,13 @@ export function VisualConfigEditor({
title: t('config_management.visual.sections.system.title'),
icon: IconDiamond,
errorCount: countErrors([
'errorLogsMaxFiles',
'logsMaxTotalSizeMb',
'redisUsageQueueRetentionSeconds',
'requestRetry',
'maxRetryCredentials',
'maxRetryInterval',
'authAutoRefreshWorkers',
]),
},
{
@@ -513,6 +539,102 @@ export function VisualConfigEditor({
</SectionStack>
</SectionSubsection>
<SectionSubsection
title={t('config_management.visual.sections.home.title')}
description={t('config_management.visual.sections.home.description')}
>
<SectionStack>
<SectionGrid>
<ToggleRow
title={t('config_management.visual.sections.home.enabled')}
description={t('config_management.visual.sections.home.enabled_desc')}
checked={values.homeEnabled}
disabled={disabled}
onChange={(homeEnabled) => onChange({ homeEnabled })}
/>
<ToggleRow
title={t('config_management.visual.sections.home.disable_cluster_discovery')}
description={t(
'config_management.visual.sections.home.disable_cluster_discovery_desc'
)}
checked={values.homeDisableClusterDiscovery}
disabled={disabled}
onChange={(homeDisableClusterDiscovery) =>
onChange({ homeDisableClusterDiscovery })
}
/>
</SectionGrid>
<SectionGrid>
<Input
label={t('config_management.visual.sections.home.host')}
placeholder="127.0.0.1"
value={values.homeHost}
onChange={(e) => onChange({ homeHost: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.home.port')}
type="number"
placeholder="6379"
value={values.homePort}
onChange={(e) => onChange({ homePort: e.target.value })}
disabled={disabled}
error={homePortError}
/>
<Input
label={t('config_management.visual.sections.home.password')}
type="password"
placeholder={t('config_management.visual.sections.home.password_placeholder')}
value={values.homePassword}
onChange={(e) => onChange({ homePassword: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
<Divider />
<div className={styles.subsectionHeader}>
<h3 className={styles.subsectionTitle}>
{t('config_management.visual.sections.home.tls_title')}
</h3>
<p className={styles.subsectionDescription}>
{t('config_management.visual.sections.home.tls_description')}
</p>
</div>
<SectionGrid>
<ToggleRow
title={t('config_management.visual.sections.home.tls_enable')}
checked={values.homeTlsEnable}
disabled={disabled}
onChange={(homeTlsEnable) => onChange({ homeTlsEnable })}
/>
<ToggleRow
title={t('config_management.visual.sections.home.tls_insecure')}
description={t('config_management.visual.sections.home.tls_insecure_desc')}
checked={values.homeTlsInsecureSkipVerify}
disabled={disabled}
onChange={(homeTlsInsecureSkipVerify) =>
onChange({ homeTlsInsecureSkipVerify })
}
/>
</SectionGrid>
<SectionGrid>
<Input
label={t('config_management.visual.sections.home.tls_server_name')}
placeholder="home.example.com"
value={values.homeTlsServerName}
onChange={(e) => onChange({ homeTlsServerName: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.home.tls_ca_cert')}
placeholder="/path/to/ca.pem"
value={values.homeTlsCaCert}
onChange={(e) => onChange({ homeTlsCaCert: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
</SectionStack>
</SectionSubsection>
<SectionSubsection
title={t('config_management.visual.sections.remote.title')}
description={t('config_management.visual.sections.remote.description')}
@@ -533,6 +655,19 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(rmDisableControlPanel) => onChange({ rmDisableControlPanel })}
/>
<ToggleRow
title={t(
'config_management.visual.sections.remote.disable_auto_update_panel'
)}
description={t(
'config_management.visual.sections.remote.disable_auto_update_panel_desc'
)}
checked={values.rmDisableAutoUpdatePanel}
disabled={disabled}
onChange={(rmDisableAutoUpdatePanel) =>
onChange({ rmDisableAutoUpdatePanel })
}
/>
</SectionGrid>
<SectionGrid>
<Input
@@ -632,7 +767,151 @@ export function VisualConfigEditor({
disabled={disabled}
error={logsMaxSizeError}
/>
<Input
label={t('config_management.visual.sections.system.error_logs_max_files')}
type="number"
placeholder="10"
value={values.errorLogsMaxFiles}
onChange={(e) => onChange({ errorLogsMaxFiles: e.target.value })}
disabled={disabled}
error={errorLogsMaxFilesError}
/>
<Input
label={t('config_management.visual.sections.system.redis_usage_retention')}
type="number"
placeholder="60"
value={values.redisUsageQueueRetentionSeconds}
onChange={(e) => onChange({ redisUsageQueueRetentionSeconds: e.target.value })}
disabled={disabled}
hint={t('config_management.visual.sections.system.redis_usage_retention_hint')}
error={redisUsageQueueRetentionError}
/>
</SectionGrid>
<SectionGrid>
<ToggleRow
title={t('config_management.visual.sections.system.usage_statistics_enabled')}
description={t(
'config_management.visual.sections.system.usage_statistics_enabled_desc'
)}
checked={values.usageStatisticsEnabled}
disabled={disabled}
onChange={(usageStatisticsEnabled) => onChange({ usageStatisticsEnabled })}
/>
<ToggleRow
title={t('config_management.visual.sections.system.antigravity_signature_cache')}
description={t(
'config_management.visual.sections.system.antigravity_signature_cache_desc'
)}
checked={values.antigravitySignatureCacheEnabled}
disabled={disabled}
onChange={(antigravitySignatureCacheEnabled) =>
onChange({ antigravitySignatureCacheEnabled })
}
/>
<ToggleRow
title={t('config_management.visual.sections.system.antigravity_signature_strict')}
description={t(
'config_management.visual.sections.system.antigravity_signature_strict_desc'
)}
checked={values.antigravitySignatureBypassStrict}
disabled={disabled}
onChange={(antigravitySignatureBypassStrict) =>
onChange({ antigravitySignatureBypassStrict })
}
/>
</SectionGrid>
<SectionSubsection
title={t('config_management.visual.sections.headers.title')}
description={t('config_management.visual.sections.headers.description')}
>
<SectionStack>
<div className={styles.subsectionHeader}>
<h3 className={styles.subsectionTitle}>
{t('config_management.visual.sections.headers.claude_title')}
</h3>
</div>
<SectionGrid>
<Input
label={t('config_management.visual.sections.headers.user_agent')}
placeholder="claude-cli/2.1.44 (external, sdk-cli)"
value={values.claudeHeaderUserAgent}
onChange={(e) => onChange({ claudeHeaderUserAgent: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.package_version')}
placeholder="0.74.0"
value={values.claudeHeaderPackageVersion}
onChange={(e) => onChange({ claudeHeaderPackageVersion: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.runtime_version')}
placeholder="v24.3.0"
value={values.claudeHeaderRuntimeVersion}
onChange={(e) => onChange({ claudeHeaderRuntimeVersion: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.os')}
placeholder="MacOS"
value={values.claudeHeaderOs}
onChange={(e) => onChange({ claudeHeaderOs: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.arch')}
placeholder="arm64"
value={values.claudeHeaderArch}
onChange={(e) => onChange({ claudeHeaderArch: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.timeout')}
placeholder="600"
value={values.claudeHeaderTimeout}
onChange={(e) => onChange({ claudeHeaderTimeout: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
<SectionGrid>
<ToggleRow
title={t('config_management.visual.sections.headers.stabilize_device')}
description={t(
'config_management.visual.sections.headers.stabilize_device_desc'
)}
checked={values.claudeHeaderStabilizeDeviceProfile}
disabled={disabled}
onChange={(claudeHeaderStabilizeDeviceProfile) =>
onChange({ claudeHeaderStabilizeDeviceProfile })
}
/>
</SectionGrid>
<Divider />
<div className={styles.subsectionHeader}>
<h3 className={styles.subsectionTitle}>
{t('config_management.visual.sections.headers.codex_title')}
</h3>
</div>
<SectionGrid>
<Input
label={t('config_management.visual.sections.headers.user_agent')}
placeholder="codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
value={values.codexHeaderUserAgent}
onChange={(e) => onChange({ codexHeaderUserAgent: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.headers.beta_features')}
placeholder="multi_agent"
value={values.codexHeaderBetaFeatures}
onChange={(e) => onChange({ codexHeaderBetaFeatures: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
</SectionStack>
</SectionSubsection>
<SectionSubsection
title={t('config_management.visual.sections.network.title')}
@@ -677,6 +956,20 @@ export function VisualConfigEditor({
disabled={disabled}
error={maxRetryIntervalError}
/>
<Input
label={t(
'config_management.visual.sections.network.auth_auto_refresh_workers'
)}
type="number"
placeholder="16"
value={values.authAutoRefreshWorkers}
onChange={(e) => onChange({ authAutoRefreshWorkers: e.target.value })}
disabled={disabled}
hint={t(
'config_management.visual.sections.network.auth_auto_refresh_workers_hint'
)}
error={authAutoRefreshWorkersError}
/>
<FieldShell
label={t('config_management.visual.sections.network.routing_strategy')}
labelId={routingStrategyLabelId}
@@ -710,6 +1003,31 @@ export function VisualConfigEditor({
}
/>
</FieldShell>
<FieldShell
label={t(
'config_management.visual.sections.network.disable_image_generation'
)}
labelId={disableImageGenerationLabelId}
hint={t(
'config_management.visual.sections.network.disable_image_generation_hint'
)}
hintId={disableImageGenerationHintId}
>
<Select
value={values.disableImageGeneration}
options={disableImageGenerationOptions}
id={`${disableImageGenerationLabelId}-select`}
disabled={disabled}
ariaLabelledBy={disableImageGenerationLabelId}
ariaDescribedBy={disableImageGenerationHintId}
onChange={(nextValue) =>
onChange({
disableImageGeneration:
nextValue as VisualConfigValues['disableImageGeneration'],
})
}
/>
</FieldShell>
<Input
label={t('config_management.visual.sections.network.session_affinity_ttl')}
placeholder="1h"
@@ -729,6 +1047,24 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(forceModelPrefix) => onChange({ forceModelPrefix })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.passthrough_headers')}
description={t(
'config_management.visual.sections.network.passthrough_headers_desc'
)}
checked={values.passthroughHeaders}
disabled={disabled}
onChange={(passthroughHeaders) => onChange({ passthroughHeaders })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.disable_cooling')}
description={t(
'config_management.visual.sections.network.disable_cooling_desc'
)}
checked={values.disableCooling}
disabled={disabled}
onChange={(disableCooling) => onChange({ disableCooling })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.session_affinity')}
checked={values.routingSessionAffinity}
@@ -742,6 +1078,17 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(wsAuth) => onChange({ wsAuth })}
/>
<ToggleRow
title={t(
'config_management.visual.sections.network.enable_gemini_cli_endpoint'
)}
description={t(
'config_management.visual.sections.network.enable_gemini_cli_endpoint_desc'
)}
checked={values.enableGeminiCliEndpoint}
disabled={disabled}
onChange={(enableGeminiCliEndpoint) => onChange({ enableGeminiCliEndpoint })}
/>
</SectionGrid>
</SectionStack>
</SectionSubsection>
@@ -8,6 +8,7 @@ import styles from './VisualConfigEditor.module.scss';
import { copyToClipboard } from '@/utils/clipboard';
import type {
PayloadFilterRule,
PayloadHeaderEntry,
PayloadModelEntry,
PayloadParamEntry,
PayloadParamValidationErrorCode,
@@ -449,6 +450,17 @@ const StringListEditor = memo(function StringListEditor({
);
});
function hasPayloadModelAdvancedSettings(model: PayloadModelEntry) {
return Boolean(
model.fromProtocol ||
(model.headers?.length ?? 0) > 0 ||
(model.match?.length ?? 0) > 0 ||
(model.notMatch?.length ?? 0) > 0 ||
(model.exist?.length ?? 0) > 0 ||
(model.notExist?.length ?? 0) > 0
);
}
export const PayloadRulesEditor = memo(function PayloadRulesEditor({
value,
disabled,
@@ -465,6 +477,31 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
const { t } = useTranslation();
const rules = value;
const protocolOptions = useMemo(() => buildProtocolOptions(t, rules), [rules, t]);
const fromProtocolOptions = useMemo(
() => [
{
value: '',
label: t('config_management.visual.payload_rules.provider_default'),
},
{
value: 'openai',
label: t('config_management.visual.payload_rules.provider_openai'),
},
{
value: 'responses',
label: t('config_management.visual.payload_rules.provider_responses'),
},
{
value: 'gemini',
label: t('config_management.visual.payload_rules.provider_gemini'),
},
{
value: 'claude',
label: t('config_management.visual.payload_rules.provider_claude'),
},
],
[t]
);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
@@ -480,6 +517,7 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
],
[t]
);
const [modelAdvancedOverrides, setModelAdvancedOverrides] = useState<Record<string, boolean>>({});
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -509,6 +547,79 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
});
};
const toggleModelAdvanced = (modelId: string, defaultExpanded: boolean) => {
setModelAdvancedOverrides((current) => ({
...current,
[modelId]: !(current[modelId] ?? defaultExpanded),
}));
};
const addHeader = (ruleIndex: number, modelIndex: number) => {
const rule = rules[ruleIndex];
const model = rule.models[modelIndex];
updateModel(ruleIndex, modelIndex, {
headers: [...(model.headers ?? []), { id: makeClientId(), name: '', value: '' }],
});
};
const updateHeader = (
ruleIndex: number,
modelIndex: number,
headerIndex: number,
patch: Partial<PayloadHeaderEntry>
) => {
const model = rules[ruleIndex].models[modelIndex];
updateModel(ruleIndex, modelIndex, {
headers: (model.headers ?? []).map((header, i) =>
i === headerIndex ? { ...header, ...patch } : header
),
});
};
const removeHeader = (ruleIndex: number, modelIndex: number, headerIndex: number) => {
const model = rules[ruleIndex].models[modelIndex];
updateModel(ruleIndex, modelIndex, {
headers: (model.headers ?? []).filter((_, i) => i !== headerIndex),
});
};
const addCondition = (ruleIndex: number, modelIndex: number, key: 'match' | 'notMatch') => {
const model = rules[ruleIndex].models[modelIndex];
updateModel(ruleIndex, modelIndex, {
[key]: [
...(model[key] ?? []),
{ id: makeClientId(), path: '', valueType: 'string', value: '' },
],
});
};
const updateCondition = (
ruleIndex: number,
modelIndex: number,
key: 'match' | 'notMatch',
conditionIndex: number,
patch: Partial<PayloadParamEntry>
) => {
const model = rules[ruleIndex].models[modelIndex];
updateModel(ruleIndex, modelIndex, {
[key]: (model[key] ?? []).map((condition, i) =>
i === conditionIndex ? { ...condition, ...patch } : condition
),
});
};
const removeCondition = (
ruleIndex: number,
modelIndex: number,
key: 'match' | 'notMatch',
conditionIndex: number
) => {
const model = rules[ruleIndex].models[modelIndex];
updateModel(ruleIndex, modelIndex, {
[key]: (model[key] ?? []).filter((_, i) => i !== conditionIndex),
});
};
const addParam = (ruleIndex: number) => {
const rule = rules[ruleIndex];
const nextParam: PayloadParamEntry = {
@@ -558,6 +669,62 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
return getValidationMessage(t, errorCode);
};
const renderConditionValueEditor = (
ruleIndex: number,
modelIndex: number,
key: 'match' | 'notMatch',
conditionIndex: number,
condition: PayloadParamEntry
) => {
if (condition.valueType === 'boolean') {
return (
<Select
value={
condition.value.toLowerCase() === 'true' || condition.value.toLowerCase() === 'false'
? condition.value.toLowerCase()
: ''
}
options={booleanValueOptions}
placeholder={t('config_management.visual.payload_rules.value_boolean')}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.condition_value')}
onChange={(nextValue) =>
updateCondition(ruleIndex, modelIndex, key, conditionIndex, { value: nextValue })
}
/>
);
}
if (condition.valueType === 'json') {
return (
<textarea
className={`input ${styles.payloadJsonInput}`}
placeholder={getValuePlaceholder(condition.valueType)}
aria-label={t('config_management.visual.payload_rules.condition_value')}
value={condition.value}
onChange={(e) =>
updateCondition(ruleIndex, modelIndex, key, conditionIndex, {
value: e.target.value,
})
}
disabled={disabled}
/>
);
}
return (
<ExpandableInput
placeholder={getValuePlaceholder(condition.valueType)}
ariaLabel={t('config_management.visual.payload_rules.condition_value')}
value={condition.value}
onChange={(nextValue) =>
updateCondition(ruleIndex, modelIndex, key, conditionIndex, { value: nextValue })
}
disabled={disabled}
/>
);
};
const renderParamValueEditor = (
ruleIndex: number,
paramIndex: number,
@@ -641,70 +808,303 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.models')}
</div>
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
<div
key={model.id}
className={[
styles.payloadRuleModelRow,
protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '',
]
.filter(Boolean)
.join(' ')}
>
{protocolFirst ? (
<>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
{(rule.models.length ? rule.models : []).map((model, modelIndex) => {
const hasAdvancedSettings = hasPayloadModelAdvancedSettings(model);
const advancedExpanded = modelAdvancedOverrides[model.id] ?? hasAdvancedSettings;
return (
<div key={model.id} className={styles.payloadModelGroup}>
<div
className={[
styles.payloadRuleModelRow,
protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '',
]
.filter(Boolean)
.join(' ')}
>
{protocolFirst ? (
<>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, { name: nextValue })
}
disabled={disabled}
/>
</>
) : (
<>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, { name: nextValue })
}
disabled={disabled}
/>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
</>
)}
<Button
variant="secondary"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => toggleModelAdvanced(model.id, hasAdvancedSettings)}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
>
{advancedExpanded
? t('config_management.visual.payload_rules.hide_advanced')
: t('config_management.visual.payload_rules.advanced')}
</Button>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
/>
</>
) : (
<>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.model_name')}
ariaLabel={t('config_management.visual.payload_rules.model_name')}
value={model.name}
onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })}
disabled={disabled}
/>
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
protocol: (nextValue || undefined) as PayloadModelEntry['protocol'],
})
}
/>
</>
)}
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
{advancedExpanded ? (
<div className={styles.payloadModelAdvanced}>
<div className={styles.payloadAdvancedGrid}>
<div className={styles.fieldShell}>
<label className={styles.fieldLabel}>
{t('config_management.visual.payload_rules.from_protocol')}
</label>
<Select
value={model.fromProtocol ?? ''}
options={fromProtocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.from_protocol')}
onChange={(nextValue) =>
updateModel(ruleIndex, modelIndex, {
fromProtocol: (nextValue ||
undefined) as PayloadModelEntry['fromProtocol'],
})
}
/>
</div>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.headers')}
</div>
{(model.headers ?? []).map((header, headerIndex) => (
<div key={header.id} className={styles.payloadHeaderRow}>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.header_name')}
ariaLabel={t('config_management.visual.payload_rules.header_name')}
value={header.name}
onChange={(nextValue) =>
updateHeader(ruleIndex, modelIndex, headerIndex, {
name: nextValue,
})
}
disabled={disabled}
/>
<ExpandableInput
placeholder={t('config_management.visual.payload_rules.header_value')}
ariaLabel={t('config_management.visual.payload_rules.header_value')}
value={header.value}
onChange={(nextValue) =>
updateHeader(ruleIndex, modelIndex, headerIndex, {
value: nextValue,
})
}
disabled={disabled}
/>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeHeader(ruleIndex, modelIndex, headerIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
))}
<div className={styles.actionRow}>
<Button
variant="secondary"
size="sm"
onClick={() => addHeader(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.payload_rules.add_header')}
</Button>
</div>
</div>
{(['match', 'notMatch'] as const).map((conditionKey) => (
<div key={conditionKey} className={styles.blockStack}>
<div className={styles.blockLabel}>
{t(`config_management.visual.payload_rules.${conditionKey}`)}
</div>
{(model[conditionKey] ?? []).map((condition, conditionIndex) => {
const conditionError = getValidationMessage(
t,
getPayloadParamValidationError(condition)
);
return (
<div key={condition.id} className={styles.payloadRuleParamGroup}>
<div className={styles.payloadRuleParamRow}>
<ExpandableInput
placeholder={t(
'config_management.visual.payload_rules.condition_path'
)}
ariaLabel={t(
'config_management.visual.payload_rules.condition_path'
)}
value={condition.path}
onChange={(nextValue) =>
updateCondition(
ruleIndex,
modelIndex,
conditionKey,
conditionIndex,
{ path: nextValue }
)
}
disabled={disabled}
/>
<Select
value={condition.valueType}
options={payloadValueTypeOptions}
disabled={disabled}
ariaLabel={t(
'config_management.visual.payload_rules.param_type'
)}
onChange={(nextValue) =>
updateCondition(
ruleIndex,
modelIndex,
conditionKey,
conditionIndex,
{
valueType: nextValue as PayloadParamValueType,
value:
nextValue === 'boolean'
? 'true'
: nextValue === 'json' &&
condition.value.trim() === ''
? '{}'
: condition.value,
}
)
}
/>
{renderConditionValueEditor(
ruleIndex,
modelIndex,
conditionKey,
conditionIndex,
condition
)}
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() =>
removeCondition(
ruleIndex,
modelIndex,
conditionKey,
conditionIndex
)
}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
{conditionError ? (
<div className={`error-box ${styles.payloadParamError}`}>
{conditionError}
</div>
) : null}
</div>
);
})}
<div className={styles.actionRow}>
<Button
variant="secondary"
size="sm"
onClick={() => addCondition(ruleIndex, modelIndex, conditionKey)}
disabled={disabled}
>
{t('config_management.visual.payload_rules.add_condition')}
</Button>
</div>
</div>
))}
<div className={styles.payloadAdvancedGrid}>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.exist')}
</div>
<StringListEditor
value={model.exist ?? []}
disabled={disabled}
placeholder={t('config_management.visual.payload_rules.condition_path')}
inputAriaLabel={t(
'config_management.visual.payload_rules.condition_path'
)}
onChange={(exist) => updateModel(ruleIndex, modelIndex, { exist })}
/>
</div>
<div className={styles.blockStack}>
<div className={styles.blockLabel}>
{t('config_management.visual.payload_rules.notExist')}
</div>
<StringListEditor
value={model.notExist ?? []}
disabled={disabled}
placeholder={t('config_management.visual.payload_rules.condition_path')}
inputAriaLabel={t(
'config_management.visual.payload_rules.condition_path'
)}
onChange={(notExist) =>
updateModel(ruleIndex, modelIndex, { notExist })
}
/>
</div>
</div>
</div>
) : null}
</div>
);
})}
<div className={styles.actionRow}>
<Button
variant="secondary"
@@ -731,7 +1131,9 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({
placeholder={t('config_management.visual.payload_rules.json_path')}
ariaLabel={t('config_management.visual.payload_rules.json_path')}
value={param.path}
onChange={(nextValue) => updateParam(ruleIndex, paramIndex, { path: nextValue })}
onChange={(nextValue) =>
updateParam(ruleIndex, paramIndex, { path: nextValue })
}
disabled={disabled}
/>
{rawJsonValues ? null : (
+469 -87
View File
@@ -1,7 +1,9 @@
import { useCallback, useMemo, useReducer } from 'react';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
DisableImageGenerationMode,
PayloadFilterRule,
PayloadHeaderEntry,
PayloadParamEntry,
PayloadParamValueType,
PayloadRule,
@@ -137,6 +139,24 @@ function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown
}
}
function setDisableImageGenerationInDoc(
doc: YamlDocument,
path: YamlPath,
value: DisableImageGenerationMode
): void {
if (value === 'chat') {
doc.setIn(path, 'chat');
return;
}
if (value === 'true') {
doc.setIn(path, true);
return;
}
if (docHas(doc, path)) doc.setIn(path, false);
}
function getNonNegativeIntegerError(value: string): 'non_negative_integer' | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
@@ -156,11 +176,17 @@ export function getVisualConfigValidationErrors(
values: VisualConfigValues
): VisualConfigValidationErrors {
return {
homePort: getPortError(values.homePort),
port: getPortError(values.port),
errorLogsMaxFiles: getNonNegativeIntegerError(values.errorLogsMaxFiles),
logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb),
redisUsageQueueRetentionSeconds: getNonNegativeIntegerError(
values.redisUsageQueueRetentionSeconds
),
requestRetry: getNonNegativeIntegerError(values.requestRetry),
maxRetryCredentials: getNonNegativeIntegerError(values.maxRetryCredentials),
maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval),
authAutoRefreshWorkers: getNonNegativeIntegerError(values.authAutoRefreshWorkers),
'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds),
'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries),
'streaming.nonstreamKeepaliveInterval': getNonNegativeIntegerError(
@@ -201,8 +227,14 @@ export function getPayloadParamValidationError(
}
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
return rules.some((rule) =>
rule.params.some((param) => Boolean(getPayloadParamValidationError(param)))
return rules.some(
(rule) =>
rule.params.some((param) => Boolean(getPayloadParamValidationError(param))) ||
rule.models.some(
(model) =>
(model.match ?? []).some((param) => Boolean(getPayloadParamValidationError(param))) ||
(model.notMatch ?? []).some((param) => Boolean(getPayloadParamValidationError(param)))
)
);
}
@@ -221,7 +253,19 @@ function arePayloadModelEntriesEqual(
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id || a.name !== b.name || a.protocol !== b.protocol) return false;
if (
a.id !== b.id ||
a.name !== b.name ||
a.protocol !== b.protocol ||
a.fromProtocol !== b.fromProtocol
) {
return false;
}
if (!arePayloadHeaderEntriesEqual(a.headers, b.headers)) return false;
if (!arePayloadParamEntriesEqual(a.match ?? [], b.match ?? [])) return false;
if (!arePayloadParamEntriesEqual(a.notMatch ?? [], b.notMatch ?? [])) return false;
if (!areStringArraysEqual(a.exist, b.exist)) return false;
if (!areStringArraysEqual(a.notExist, b.notExist)) return false;
}
return true;
}
@@ -243,6 +287,34 @@ function arePayloadParamEntriesEqual(
return true;
}
function arePayloadHeaderEntriesEqual(
left: PayloadHeaderEntry[] | undefined,
right: PayloadHeaderEntry[] | undefined
): boolean {
const leftEntries = left ?? [];
const rightEntries = right ?? [];
if (leftEntries === rightEntries) return true;
if (leftEntries.length !== rightEntries.length) return false;
for (let i = 0; i < leftEntries.length; i += 1) {
const a = leftEntries[i];
const b = rightEntries[i];
if (!a || !b) return false;
if (a.id !== b.id || a.name !== b.name || a.value !== b.value) return false;
}
return true;
}
function areStringArraysEqual(left: string[] | undefined, right: string[] | undefined): boolean {
const leftItems = left ?? [];
const rightItems = right ?? [];
if (leftItems === rightItems) return true;
if (leftItems.length !== rightItems.length) return false;
for (let i = 0; i < leftItems.length; i += 1) {
if (leftItems[i] !== rightItems[i]) return false;
}
return true;
}
function arePayloadRulesEqual(left: PayloadRule[], right: PayloadRule[]): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
@@ -314,6 +386,63 @@ function parsePayloadProtocol(raw: unknown): string | undefined {
return raw.trim() ? raw : undefined;
}
function parseDisableImageGenerationMode(raw: unknown): DisableImageGenerationMode {
if (raw === true) return 'true';
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
if (normalized === 'true') return 'true';
if (normalized === 'chat') return 'chat';
}
return 'false';
}
function parsePayloadHeaders(raw: unknown, idPrefix: string): PayloadHeaderEntry[] {
const record = asRecord(raw);
if (!record) return [];
return Object.entries(record).map(([name, value], index) => ({
id: `${idPrefix}-header-${index}`,
name,
value: String(value ?? ''),
}));
}
function parsePayloadConditions(raw: unknown, idPrefix: string): PayloadParamEntry[] {
if (!Array.isArray(raw)) return [];
const entries: PayloadParamEntry[] = [];
raw.forEach((item, itemIndex) => {
const record = asRecord(item);
if (!record) {
if (typeof item === 'string') {
entries.push({
id: `${idPrefix}-condition-${itemIndex}-0`,
path: item,
valueType: 'string',
value: '',
});
}
return;
}
Object.entries(record).forEach(([path, value], valueIndex) => {
const parsedValue = parsePayloadParamValue(value);
entries.push({
id: `${idPrefix}-condition-${itemIndex}-${valueIndex}`,
path,
valueType: parsedValue.valueType,
value: parsedValue.value,
});
});
});
return entries;
}
function parseStringList(raw: unknown): string[] {
return Array.isArray(raw) ? raw.map((item) => String(item ?? '').trim()).filter(Boolean) : [];
}
function deleteLegacyApiKeysProvider(doc: YamlDocument): void {
if (docHas(doc, ['auth', 'providers', 'config-api-key', 'api-key-entries'])) {
doc.deleteIn(['auth', 'providers', 'config-api-key', 'api-key-entries']);
@@ -326,26 +455,37 @@ function deleteLegacyApiKeysProvider(doc: YamlDocument): void {
deleteIfMapEmpty(doc, ['auth']);
}
function parsePayloadModelEntries(raw: unknown, idPrefix: string): PayloadRule['models'] {
if (!Array.isArray(raw)) return [];
return raw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
const modelId = `${idPrefix}-${modelIndex}`;
return {
id: modelId,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
fromProtocol: parsePayloadProtocol(modelRecord?.['from-protocol']),
headers: parsePayloadHeaders(modelRecord?.headers, modelId),
match: parsePayloadConditions(modelRecord?.match, `${modelId}-match`),
notMatch: parsePayloadConditions(modelRecord?.['not-match'], `${modelId}-not-match`),
exist: parseStringList(modelRecord?.exist),
notExist: parseStringList(modelRecord?.['not-exist']),
};
});
}
function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const models = parsePayloadModelEntries(record.models, `model-${index}`);
const paramsRecord = asRecord(record.params);
const params = paramsRecord
@@ -370,20 +510,7 @@ function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `filter-model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const models = parsePayloadModelEntries(record.models, `filter-model-${index}`);
const paramsRaw = record.params;
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
@@ -398,20 +525,7 @@ function parseRawPayloadRules(rules: unknown): PayloadRule[] {
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `raw-model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const models = parsePayloadModelEntries(record.models, `raw-model-${index}`);
const paramsRecord = asRecord(record.params);
const params = paramsRecord
@@ -427,34 +541,88 @@ function parseRawPayloadRules(rules: unknown): PayloadRule[] {
});
}
function serializePayloadParamEntryValue(param: PayloadParamEntry): unknown {
if (param.valueType === 'number') {
const num = Number(param.value);
return Number.isFinite(num) ? num : param.value;
}
if (param.valueType === 'boolean') {
return param.value === 'true';
}
if (param.valueType === 'json') {
try {
return JSON.parse(param.value);
} catch {
return param.value;
}
}
return param.value;
}
function serializePayloadHeadersForYaml(headers?: PayloadHeaderEntry[]): Record<string, string> {
const result: Record<string, string> = {};
for (const header of headers ?? []) {
const name = header.name.trim();
if (!name) continue;
result[name] = header.value;
}
return result;
}
function serializePayloadConditionsForYaml(
conditions?: PayloadParamEntry[]
): Array<Record<string, unknown>> {
const result: Array<Record<string, unknown>> = [];
for (const condition of conditions ?? []) {
const path = condition.path.trim();
if (!path) continue;
result.push({ [path]: serializePayloadParamEntryValue(condition) });
}
return result;
}
function serializeStringListForYaml(items?: string[]): string[] {
return (items ?? []).map((item) => item.trim()).filter(Boolean);
}
function serializePayloadModelsForYaml(
models: PayloadRule['models']
): Array<Record<string, unknown>> {
return (models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
if (m.fromProtocol) obj['from-protocol'] = m.fromProtocol;
const headers = serializePayloadHeadersForYaml(m.headers);
if (Object.keys(headers).length) obj.headers = headers;
const match = serializePayloadConditionsForYaml(m.match);
if (match.length) obj.match = match;
const notMatch = serializePayloadConditionsForYaml(m.notMatch);
if (notMatch.length) obj['not-match'] = notMatch;
const exist = serializeStringListForYaml(m.exist);
if (exist.length) obj.exist = exist;
const notExist = serializeStringListForYaml(m.notExist);
if (notExist.length) obj['not-exist'] = notExist;
return obj;
});
}
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const models = serializePayloadModelsForYaml(rule.models);
const params: Record<string, unknown> = {};
for (const param of rule.params || []) {
if (!param.path?.trim()) continue;
let value: unknown = param.value;
if (param.valueType === 'number') {
const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value;
} else if (param.valueType === 'boolean') {
value = param.value === 'true';
} else if (param.valueType === 'json') {
try {
value = JSON.parse(param.value);
} catch {
value = param.value;
}
}
params[param.path.trim()] = value;
params[param.path.trim()] = serializePayloadParamEntryValue(param);
}
return { models, params };
@@ -467,13 +635,7 @@ function serializePayloadFilterRulesForYaml(
): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const models = serializePayloadModelsForYaml(rule.models);
const params = (Array.isArray(rule.params) ? rule.params : [])
.map((path) => String(path).trim())
@@ -487,13 +649,7 @@ function serializePayloadFilterRulesForYaml(
function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const models = serializePayloadModelsForYaml(rule.models);
const params: Record<string, unknown> = {};
for (const param of rule.params || []) {
@@ -562,6 +718,45 @@ function getNextDirtyFields(
nextDirtyFields.add(key);
}
};
const updateScalarDirty = (key: keyof VisualConfigValues) => {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
updateDirty(key, nextValues[key] === baselineValues[key]);
}
};
(
[
'homeEnabled',
'homeHost',
'homePort',
'homePassword',
'homeDisableClusterDiscovery',
'homeTlsEnable',
'homeTlsServerName',
'homeTlsCaCert',
'homeTlsInsecureSkipVerify',
'rmDisableAutoUpdatePanel',
'errorLogsMaxFiles',
'usageStatisticsEnabled',
'redisUsageQueueRetentionSeconds',
'passthroughHeaders',
'disableCooling',
'disableImageGeneration',
'authAutoRefreshWorkers',
'enableGeminiCliEndpoint',
'antigravitySignatureCacheEnabled',
'antigravitySignatureBypassStrict',
'claudeHeaderUserAgent',
'claudeHeaderPackageVersion',
'claudeHeaderRuntimeVersion',
'claudeHeaderOs',
'claudeHeaderArch',
'claudeHeaderTimeout',
'claudeHeaderStabilizeDeviceProfile',
'codexHeaderUserAgent',
'codexHeaderBetaFeatures',
] as Array<keyof VisualConfigValues>
).forEach(updateScalarDirty);
if (Object.prototype.hasOwnProperty.call(patch, 'host')) {
updateDirty('host', nextValues.host === baselineValues.host);
@@ -806,11 +1001,15 @@ export function useVisualConfig() {
const parsedRaw: unknown = parseYaml(yamlContent) || {};
const parsed = asRecord(parsedRaw) ?? {};
const tls = asRecord(parsed.tls);
const home = asRecord(parsed.home);
const homeTls = asRecord(home?.tls);
const remoteManagement = asRecord(parsed['remote-management']);
const quotaExceeded = asRecord(parsed['quota-exceeded']);
const routing = asRecord(parsed.routing);
const payload = asRecord(parsed.payload);
const streaming = asRecord(parsed.streaming);
const claudeHeaderDefaults = asRecord(parsed['claude-header-defaults']);
const codexHeaderDefaults = asRecord(parsed['codex-header-defaults']);
const newValues: VisualConfigValues = {
host: typeof parsed.host === 'string' ? parsed.host : '',
@@ -820,12 +1019,24 @@ export function useVisualConfig() {
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
homeEnabled: Boolean(home?.enabled),
homeHost: typeof home?.host === 'string' ? home.host : '',
homePort: String(home?.port ?? ''),
homePassword: typeof home?.password === 'string' ? home.password : '',
homeDisableClusterDiscovery: Boolean(home?.['disable-cluster-discovery']),
homeTlsEnable: Boolean(homeTls?.enable),
homeTlsServerName:
typeof homeTls?.['server-name'] === 'string' ? homeTls['server-name'] : '',
homeTlsCaCert: typeof homeTls?.['ca-cert'] === 'string' ? homeTls['ca-cert'] : '',
homeTlsInsecureSkipVerify: Boolean(homeTls?.['insecure-skip-verify']),
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey:
typeof remoteManagement?.['secret-key'] === 'string'
? remoteManagement['secret-key']
: '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmDisableAutoUpdatePanel: Boolean(remoteManagement?.['disable-auto-update-panel']),
rmPanelRepo:
typeof remoteManagement?.['panel-github-repository'] === 'string'
? remoteManagement['panel-github-repository']
@@ -840,13 +1051,56 @@ export function useVisualConfig() {
commercialMode: Boolean(parsed['commercial-mode']),
loggingToFile: Boolean(parsed['logging-to-file']),
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
errorLogsMaxFiles: String(parsed['error-logs-max-files'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
redisUsageQueueRetentionSeconds: String(
parsed['redis-usage-queue-retention-seconds'] ?? ''
),
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
passthroughHeaders: Boolean(parsed['passthrough-headers']),
requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryCredentials: String(parsed['max-retry-credentials'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
disableCooling: Boolean(parsed['disable-cooling']),
disableImageGeneration: parseDisableImageGenerationMode(parsed['disable-image-generation']),
authAutoRefreshWorkers: String(parsed['auth-auto-refresh-workers'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']),
enableGeminiCliEndpoint: Boolean(parsed['enable-gemini-cli-endpoint']),
antigravitySignatureCacheEnabled: Boolean(
parsed['antigravity-signature-cache-enabled'] ?? true
),
antigravitySignatureBypassStrict: Boolean(parsed['antigravity-signature-bypass-strict']),
claudeHeaderUserAgent:
typeof claudeHeaderDefaults?.['user-agent'] === 'string'
? claudeHeaderDefaults['user-agent']
: '',
claudeHeaderPackageVersion:
typeof claudeHeaderDefaults?.['package-version'] === 'string'
? claudeHeaderDefaults['package-version']
: '',
claudeHeaderRuntimeVersion:
typeof claudeHeaderDefaults?.['runtime-version'] === 'string'
? claudeHeaderDefaults['runtime-version']
: '',
claudeHeaderOs: typeof claudeHeaderDefaults?.os === 'string' ? claudeHeaderDefaults.os : '',
claudeHeaderArch:
typeof claudeHeaderDefaults?.arch === 'string' ? claudeHeaderDefaults.arch : '',
claudeHeaderTimeout:
typeof claudeHeaderDefaults?.timeout === 'string' ? claudeHeaderDefaults.timeout : '',
claudeHeaderStabilizeDeviceProfile: Boolean(
claudeHeaderDefaults?.['stabilize-device-profile']
),
codexHeaderUserAgent:
typeof codexHeaderDefaults?.['user-agent'] === 'string'
? codexHeaderDefaults['user-agent']
: '',
codexHeaderBetaFeatures:
typeof codexHeaderDefaults?.['beta-features'] === 'string'
? codexHeaderDefaults['beta-features']
: '',
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
@@ -854,9 +1108,7 @@ export function useVisualConfig() {
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
routingSessionAffinity: Boolean(
routing?.['session-affinity'] ??
routing?.sessionAffinity ??
routing?.['sessionAffinity']
routing?.['session-affinity'] ?? routing?.sessionAffinity ?? routing?.['sessionAffinity']
),
routingSessionAffinityTTL:
typeof routing?.['session-affinity-ttl'] === 'string'
@@ -915,11 +1167,57 @@ export function useVisualConfig() {
deleteIfMapEmpty(doc, ['tls']);
}
if (
docHas(doc, ['home']) ||
values.homeEnabled ||
values.homeHost.trim() ||
values.homePort.trim() ||
values.homePassword.trim() ||
values.homeDisableClusterDiscovery ||
values.homeTlsEnable ||
values.homeTlsServerName.trim() ||
values.homeTlsCaCert.trim() ||
values.homeTlsInsecureSkipVerify
) {
ensureMapInDoc(doc, ['home']);
setBooleanInDoc(doc, ['home', 'enabled'], values.homeEnabled);
setStringInDoc(doc, ['home', 'host'], values.homeHost);
setIntFromStringInDoc(doc, ['home', 'port'], values.homePort);
setStringInDoc(doc, ['home', 'password'], values.homePassword);
setBooleanInDoc(
doc,
['home', 'disable-cluster-discovery'],
values.homeDisableClusterDiscovery
);
if (
docHas(doc, ['home', 'tls']) ||
values.homeTlsEnable ||
values.homeTlsServerName.trim() ||
values.homeTlsCaCert.trim() ||
values.homeTlsInsecureSkipVerify
) {
ensureMapInDoc(doc, ['home', 'tls']);
setBooleanInDoc(doc, ['home', 'tls', 'enable'], values.homeTlsEnable);
setStringInDoc(doc, ['home', 'tls', 'server-name'], values.homeTlsServerName);
setStringInDoc(doc, ['home', 'tls', 'ca-cert'], values.homeTlsCaCert);
setBooleanInDoc(
doc,
['home', 'tls', 'insecure-skip-verify'],
values.homeTlsInsecureSkipVerify
);
deleteIfMapEmpty(doc, ['home', 'tls']);
}
deleteIfMapEmpty(doc, ['home']);
}
if (
docHas(doc, ['remote-management']) ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmDisableAutoUpdatePanel ||
values.rmPanelRepo.trim()
) {
ensureMapInDoc(doc, ['remote-management']);
@@ -930,6 +1228,11 @@ export function useVisualConfig() {
['remote-management', 'disable-control-panel'],
values.rmDisableControlPanel
);
setBooleanInDoc(
doc,
['remote-management', 'disable-auto-update-panel'],
values.rmDisableAutoUpdatePanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
@@ -954,13 +1257,95 @@ export function useVisualConfig() {
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setIntFromStringInDoc(doc, ['error-logs-max-files'], values.errorLogsMaxFiles);
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setIntFromStringInDoc(
doc,
['redis-usage-queue-retention-seconds'],
values.redisUsageQueueRetentionSeconds
);
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setBooleanInDoc(doc, ['passthrough-headers'], values.passthroughHeaders);
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromStringInDoc(doc, ['max-retry-credentials'], values.maxRetryCredentials);
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBooleanInDoc(doc, ['disable-cooling'], values.disableCooling);
setDisableImageGenerationInDoc(
doc,
['disable-image-generation'],
values.disableImageGeneration
);
setIntFromStringInDoc(doc, ['auth-auto-refresh-workers'], values.authAutoRefreshWorkers);
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
setBooleanInDoc(doc, ['enable-gemini-cli-endpoint'], values.enableGeminiCliEndpoint);
if (
docHas(doc, ['antigravity-signature-cache-enabled']) ||
!values.antigravitySignatureCacheEnabled
) {
doc.setIn(
['antigravity-signature-cache-enabled'],
values.antigravitySignatureCacheEnabled
);
}
setBooleanInDoc(
doc,
['antigravity-signature-bypass-strict'],
values.antigravitySignatureBypassStrict
);
if (
docHas(doc, ['claude-header-defaults']) ||
values.claudeHeaderUserAgent.trim() ||
values.claudeHeaderPackageVersion.trim() ||
values.claudeHeaderRuntimeVersion.trim() ||
values.claudeHeaderOs.trim() ||
values.claudeHeaderArch.trim() ||
values.claudeHeaderTimeout.trim() ||
values.claudeHeaderStabilizeDeviceProfile
) {
ensureMapInDoc(doc, ['claude-header-defaults']);
setStringInDoc(
doc,
['claude-header-defaults', 'user-agent'],
values.claudeHeaderUserAgent
);
setStringInDoc(
doc,
['claude-header-defaults', 'package-version'],
values.claudeHeaderPackageVersion
);
setStringInDoc(
doc,
['claude-header-defaults', 'runtime-version'],
values.claudeHeaderRuntimeVersion
);
setStringInDoc(doc, ['claude-header-defaults', 'os'], values.claudeHeaderOs);
setStringInDoc(doc, ['claude-header-defaults', 'arch'], values.claudeHeaderArch);
setStringInDoc(doc, ['claude-header-defaults', 'timeout'], values.claudeHeaderTimeout);
setBooleanInDoc(
doc,
['claude-header-defaults', 'stabilize-device-profile'],
values.claudeHeaderStabilizeDeviceProfile
);
deleteIfMapEmpty(doc, ['claude-header-defaults']);
}
if (
docHas(doc, ['codex-header-defaults']) ||
values.codexHeaderUserAgent.trim() ||
values.codexHeaderBetaFeatures.trim()
) {
ensureMapInDoc(doc, ['codex-header-defaults']);
setStringInDoc(doc, ['codex-header-defaults', 'user-agent'], values.codexHeaderUserAgent);
setStringInDoc(
doc,
['codex-header-defaults', 'beta-features'],
values.codexHeaderBetaFeatures
);
deleteIfMapEmpty(doc, ['codex-header-defaults']);
}
if (
docHas(doc, ['quota-exceeded']) ||
@@ -983,10 +1368,7 @@ export function useVisualConfig() {
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
if (writeQuotaAntigravityCredits) {
doc.setIn(
['quota-exceeded', 'antigravity-credits'],
values.quotaAntigravityCredits
);
doc.setIn(['quota-exceeded', 'antigravity-credits'], values.quotaAntigravityCredits);
}
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
+75 -2
View File
@@ -1085,6 +1085,25 @@
"cert": "Certificate File Path",
"key": "Private Key File Path"
},
"home": {
"title": "Home Control Plane",
"description": "Optional Home control plane connection over Redis protocol",
"enabled": "Enable Home",
"enabled_desc": "Enable the outbound connection to the Home control plane",
"host": "Home Host",
"port": "Home Port",
"password": "Home Password",
"password_placeholder": "Leave empty for no password",
"disable_cluster_discovery": "Disable Cluster Discovery",
"disable_cluster_discovery_desc": "Keep using the configured Home address instead of CLUSTER NODES entries",
"tls_title": "Home TLS",
"tls_description": "TLS, SNI, and certificate settings for the Home Redis connection",
"tls_enable": "Enable Home TLS",
"tls_server_name": "TLS Server Name",
"tls_ca_cert": "CA Certificate Path",
"tls_insecure": "Skip Certificate Verification",
"tls_insecure_desc": "Only use this for testing self-signed endpoints"
},
"remote": {
"title": "Remote Management",
"description": "Remote access and control panel settings",
@@ -1092,6 +1111,8 @@
"allow_remote_desc": "Allow management access from other hosts",
"disable_panel": "Disable Control Panel",
"disable_panel_desc": "Disable the built-in web control panel",
"disable_auto_update_panel": "Disable Panel Auto Updates",
"disable_auto_update_panel_desc": "Download the panel when first missing, but never auto-update it from GitHub",
"secret_key": "Management Key",
"secret_key_placeholder": "Set management key",
"panel_repo": "Panel Repository"
@@ -1111,7 +1132,31 @@
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to files",
"logs_max_size": "Log File Size Limit (MB)"
"logs_max_size": "Log File Size Limit (MB)",
"error_logs_max_files": "Retained Error Log Files",
"usage_statistics_enabled": "Enable In-memory Usage Statistics",
"usage_statistics_enabled_desc": "Aggregate request usage statistics in memory",
"redis_usage_retention": "Redis Usage Queue Retention (seconds)",
"redis_usage_retention_hint": "In-memory retention for RESP LPOP/RPOP usage output, max 3600 seconds",
"antigravity_signature_cache": "Enable Antigravity Signature Cache",
"antigravity_signature_cache_desc": "Prefer and validate cached thinking-block signatures",
"antigravity_signature_strict": "Strict Bypass Signature Validation",
"antigravity_signature_strict_desc": "Only applies when the signature cache is disabled; validates the full Claude protobuf tree"
},
"headers": {
"title": "Header Defaults",
"description": "Default headers for Claude and Codex OAuth requests when the client omits them",
"claude_title": "Claude Header Defaults",
"codex_title": "Codex Header Defaults",
"user_agent": "User-Agent",
"package_version": "Package Version",
"runtime_version": "Runtime Version",
"os": "OS",
"arch": "Arch",
"timeout": "Timeout",
"stabilize_device": "Stabilize Device Profile",
"stabilize_device_desc": "Pin OS/Arch and stabilize the software fingerprint per credential/API key",
"beta_features": "Beta Features"
},
"network": {
"title": "Network Configuration",
@@ -1121,6 +1166,13 @@
"max_retry_credentials": "Max Retry Credentials",
"max_retry_credentials_hint": "Leave empty to keep it unset. Set to 0 to preserve legacy behavior and try all available credentials.",
"max_retry_interval": "Max Retry Interval (seconds)",
"auth_auto_refresh_workers": "Auth Auto-refresh Workers",
"auth_auto_refresh_workers_hint": "When greater than 0, overrides the default worker count (16)",
"disable_image_generation": "Disable Image Generation",
"disable_image_generation_hint": "false enables it; true disables all image generation; chat only disables non-image endpoint injection",
"disable_image_generation_false": "false (enabled)",
"disable_image_generation_true": "true (disabled everywhere)",
"disable_image_generation_chat": "chat (disable chat injection only)",
"routing_strategy": "Routing Strategy",
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
@@ -1128,9 +1180,15 @@
"session_affinity_ttl": "Session Affinity TTL",
"force_model_prefix": "Force Model Prefix",
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
"passthrough_headers": "Pass Through Upstream Headers",
"passthrough_headers_desc": "Forward filtered upstream response headers to downstream clients",
"disable_cooling": "Disable Cooling",
"disable_cooling_desc": "Globally disable auth/model cooldown windows after failures",
"session_affinity": "Session Affinity Routing",
"ws_auth": "WebSocket Authentication",
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)",
"enable_gemini_cli_endpoint": "Enable Gemini CLI Internal Endpoint",
"enable_gemini_cli_endpoint_desc": "Enable /v1internal:* compatibility endpoints"
},
"quota": {
"title": "Quota Fallback",
@@ -1199,10 +1257,25 @@
"provider_default": "Default",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_responses": "Responses",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"advanced": "Advanced",
"hide_advanced": "Hide Advanced",
"from_protocol": "Source Protocol (from-protocol)",
"headers": "Request Header Matches",
"header_name": "Header Name",
"header_value": "Header Value",
"add_header": "Add Header",
"match": "Required Matches (match)",
"notMatch": "Forbidden Matches (not-match)",
"exist": "Required Paths (exist)",
"notExist": "Forbidden Paths (not-exist)",
"condition_path": "Match Path",
"condition_value": "Match Value",
"add_condition": "Add Condition",
"value_type_string": "String",
"value_type_number": "Number",
"value_type_boolean": "Boolean",
+75 -2
View File
@@ -1082,6 +1082,25 @@
"cert": "Путь к сертификату",
"key": "Путь к закрытому ключу"
},
"home": {
"title": "Панель управления Home",
"description": "Необязательное подключение к Home по протоколу Redis",
"enabled": "Включить Home",
"enabled_desc": "Включить исходящее подключение к панели управления Home",
"host": "Хост Home",
"port": "Порт Home",
"password": "Пароль Home",
"password_placeholder": "Оставьте пустым, если пароль не нужен",
"disable_cluster_discovery": "Отключить обнаружение кластера",
"disable_cluster_discovery_desc": "Использовать заданный адрес Home вместо записей CLUSTER NODES",
"tls_title": "TLS для Home",
"tls_description": "Настройки TLS, SNI и сертификатов для Redis-подключения Home",
"tls_enable": "Включить TLS для Home",
"tls_server_name": "TLS Server Name",
"tls_ca_cert": "Путь к CA-сертификату",
"tls_insecure": "Пропустить проверку сертификата",
"tls_insecure_desc": "Используйте только для тестирования самоподписанных endpoints"
},
"remote": {
"title": "Удалённое управление",
"description": "Настройки удалённого доступа и панели управления",
@@ -1089,6 +1108,8 @@
"allow_remote_desc": "Разрешить управление с других хостов",
"disable_panel": "Отключить панель",
"disable_panel_desc": "Отключить встроенную веб-панель управления",
"disable_auto_update_panel": "Отключить автообновление панели",
"disable_auto_update_panel_desc": "Скачивать панель при первом отсутствии, но не обновлять её автоматически с GitHub",
"secret_key": "Ключ управления",
"secret_key_placeholder": "Задайте ключ управления",
"panel_repo": "Репозиторий панели"
@@ -1108,7 +1129,31 @@
"commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки",
"logging_to_file": "Журналировать в файл",
"logging_to_file_desc": "Сохранять журналы в файлы",
"logs_max_size": "Максимальный размер файла журнала (МБ)"
"logs_max_size": "Максимальный размер файла журнала (МБ)",
"error_logs_max_files": "Файлов журнала ошибок",
"usage_statistics_enabled": "Включить статистику использования в памяти",
"usage_statistics_enabled_desc": "Агрегировать статистику использования запросов в памяти",
"redis_usage_retention": "Хранение очереди Redis usage (сек)",
"redis_usage_retention_hint": "Время хранения вывода RESP LPOP/RPOP в памяти, максимум 3600 секунд",
"antigravity_signature_cache": "Включить кэш подписей Antigravity",
"antigravity_signature_cache_desc": "Предпочитать и проверять кэшированные подписи thinking-блоков",
"antigravity_signature_strict": "Строгая проверка bypass-подписи",
"antigravity_signature_strict_desc": "Применяется только при отключённом кэше подписей; проверяет полное дерево Claude protobuf"
},
"headers": {
"title": "Заголовки по умолчанию",
"description": "Заголовки по умолчанию для OAuth-запросов Claude и Codex, когда клиент их не отправил",
"claude_title": "Claude Header Defaults",
"codex_title": "Codex Header Defaults",
"user_agent": "User-Agent",
"package_version": "Package Version",
"runtime_version": "Runtime Version",
"os": "OS",
"arch": "Arch",
"timeout": "Timeout",
"stabilize_device": "Стабилизировать профиль устройства",
"stabilize_device_desc": "Фиксировать OS/Arch и стабилизировать программный отпечаток для учётных данных/API-ключа",
"beta_features": "Beta Features"
},
"network": {
"title": "Сетевые настройки",
@@ -1118,6 +1163,13 @@
"max_retry_credentials": "Максимум учётных данных для повторов",
"max_retry_credentials_hint": "Оставьте пустым, чтобы не задавать поле. Значение 0 сохраняет legacy-поведение и позволяет перебрать все доступные учётные данные.",
"max_retry_interval": "Максимальный интервал повтора (сек)",
"auth_auto_refresh_workers": "Workers автообновления auth",
"auth_auto_refresh_workers_hint": "Если больше 0, переопределяет число workers по умолчанию (16)",
"disable_image_generation": "Отключить генерацию изображений",
"disable_image_generation_hint": "false включает; true отключает везде; chat отключает только инъекцию на не-image endpoints",
"disable_image_generation_false": "false (включено)",
"disable_image_generation_true": "true (отключено везде)",
"disable_image_generation_chat": "chat (только отключить chat-инъекцию)",
"routing_strategy": "Стратегия маршрутизации",
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
"strategy_round_robin": "По кругу",
@@ -1125,9 +1177,15 @@
"session_affinity_ttl": "TTL привязки сессии",
"force_model_prefix": "Принудительный префикс модели",
"force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса",
"passthrough_headers": "Передавать upstream-заголовки",
"passthrough_headers_desc": "Передавать отфильтрованные upstream-заголовки ответа downstream-клиентам",
"disable_cooling": "Отключить cooldown",
"disable_cooling_desc": "Глобально отключить окна охлаждения auth/model после ошибок",
"session_affinity": "Маршрутизация с привязкой к сессии",
"ws_auth": "Аутентификация WebSocket",
"ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)"
"ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)",
"enable_gemini_cli_endpoint": "Включить внутренний endpoint Gemini CLI",
"enable_gemini_cli_endpoint_desc": "Включить совместимые endpoints /v1internal:*"
},
"quota": {
"title": "Резерв по квоте",
@@ -1196,10 +1254,25 @@
"provider_default": "По умолчанию",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_responses": "Responses",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"advanced": "Дополнительно",
"hide_advanced": "Скрыть дополнительно",
"from_protocol": "Исходный протокол (from-protocol)",
"headers": "Совпадения заголовков запроса",
"header_name": "Имя заголовка",
"header_value": "Значение заголовка",
"add_header": "Добавить заголовок",
"match": "Обязательные совпадения (match)",
"notMatch": "Запрещённые совпадения (not-match)",
"exist": "Обязательные пути (exist)",
"notExist": "Запрещённые пути (not-exist)",
"condition_path": "Путь условия",
"condition_value": "Значение условия",
"add_condition": "Добавить условие",
"value_type_string": "Строка",
"value_type_number": "Число",
"value_type_boolean": "Булево",
+75 -2
View File
@@ -1085,6 +1085,25 @@
"cert": "证书文件路径",
"key": "私钥文件路径"
},
"home": {
"title": "Home 控制平面",
"description": "通过 Redis 协议连接 Home 控制平面的可选设置",
"enabled": "启用 Home",
"enabled_desc": "启用到 Home 控制平面的出站连接",
"host": "Home 主机",
"port": "Home 端口",
"password": "Home 密码",
"password_placeholder": "留空表示不设置密码",
"disable_cluster_discovery": "禁用集群发现",
"disable_cluster_discovery_desc": "固定使用配置中的 Home 地址,不切换到 CLUSTER NODES 返回的地址",
"tls_title": "Home TLS",
"tls_description": "Home Redis 连接的 TLS/SNI 与证书设置",
"tls_enable": "启用 Home TLS",
"tls_server_name": "TLS Server Name",
"tls_ca_cert": "CA 证书路径",
"tls_insecure": "跳过证书校验",
"tls_insecure_desc": "仅用于测试自签名端点"
},
"remote": {
"title": "远程管理",
"description": "远程访问和控制面板设置",
@@ -1092,6 +1111,8 @@
"allow_remote_desc": "允许从其他主机访问管理接口",
"disable_panel": "禁用控制面板",
"disable_panel_desc": "禁用内置的 Web 控制面板",
"disable_auto_update_panel": "禁用面板自动更新",
"disable_auto_update_panel_desc": "首次缺失时仍可下载,但不再从 GitHub 后台自动更新",
"secret_key": "管理密钥",
"secret_key_placeholder": "设置管理密钥",
"panel_repo": "面板仓库"
@@ -1111,7 +1132,31 @@
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到文件",
"logs_max_size": "日志文件大小限制 (MB)"
"logs_max_size": "日志文件大小限制 (MB)",
"error_logs_max_files": "错误日志保留文件数",
"usage_statistics_enabled": "启用内存用量统计",
"usage_statistics_enabled_desc": "启用内存中的请求用量聚合",
"redis_usage_retention": "Redis 用量队列保留秒数",
"redis_usage_retention_hint": "RESP 接口 LPOP/RPOP 用量队列的内存保留时间,最大 3600 秒",
"antigravity_signature_cache": "启用 Antigravity 签名缓存",
"antigravity_signature_cache_desc": "优先使用并校验缓存的 thinking block 签名",
"antigravity_signature_strict": "严格校验旁路签名",
"antigravity_signature_strict_desc": "仅在关闭签名缓存时生效,按完整 Claude protobuf 树校验"
},
"headers": {
"title": "Header 默认值",
"description": "Claude 与 Codex OAuth 请求在客户端缺少 Header 时使用的默认值",
"claude_title": "Claude Header Defaults",
"codex_title": "Codex Header Defaults",
"user_agent": "User-Agent",
"package_version": "Package Version",
"runtime_version": "Runtime Version",
"os": "OS",
"arch": "Arch",
"timeout": "Timeout",
"stabilize_device": "稳定设备指纹",
"stabilize_device_desc": "固定 OS/Arch,并按凭据/API Key 稳定软件指纹",
"beta_features": "Beta Features"
},
"network": {
"title": "网络配置",
@@ -1121,6 +1166,13 @@
"max_retry_credentials": "最大重试凭据数",
"max_retry_credentials_hint": "留空表示不设置;设为 0 表示保留 legacy 行为,并尝试所有可用凭据。",
"max_retry_interval": "最大重试间隔 (秒)",
"auth_auto_refresh_workers": "认证自动刷新 Worker 数",
"auth_auto_refresh_workers_hint": "大于 0 时覆盖默认 Worker 数(16",
"disable_image_generation": "禁用图像生成",
"disable_image_generation_hint": "false 为启用;true 为全部禁用;chat 仅禁用非图片端点注入",
"disable_image_generation_false": "false(启用)",
"disable_image_generation_true": "true(全部禁用)",
"disable_image_generation_chat": "chat(仅禁用聊天注入)",
"routing_strategy": "路由策略",
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
@@ -1128,9 +1180,15 @@
"session_affinity_ttl": "会话粘性 TTL",
"force_model_prefix": "强制模型前缀",
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
"passthrough_headers": "透传上游响应 Header",
"passthrough_headers_desc": "将过滤后的上游响应 Header 转发给下游客户端",
"disable_cooling": "禁用冷却调度",
"disable_cooling_desc": "全局禁用认证/模型失败后的冷却窗口",
"session_affinity": "会话粘性路由",
"ws_auth": "WebSocket 认证",
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)",
"enable_gemini_cli_endpoint": "启用 Gemini CLI 内部端点",
"enable_gemini_cli_endpoint_desc": "启用 /v1internal:* 兼容端点"
},
"quota": {
"title": "配额回退",
@@ -1199,10 +1257,25 @@
"provider_default": "默认",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_responses": "Responses",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"advanced": "高级",
"hide_advanced": "收起高级",
"from_protocol": "来源协议 (from-protocol)",
"headers": "请求 Header 匹配",
"header_name": "Header 名称",
"header_value": "Header 值",
"add_header": "添加 Header",
"match": "必须匹配 (match)",
"notMatch": "不得匹配 (not-match)",
"exist": "必须存在 (exist)",
"notExist": "不得存在 (not-exist)",
"condition_path": "匹配路径",
"condition_value": "匹配值",
"add_condition": "添加条件",
"value_type_string": "字符串",
"value_type_number": "数字",
"value_type_boolean": "布尔",
+75 -2
View File
@@ -1111,6 +1111,25 @@
"cert": "憑證檔案路徑",
"key": "私鑰檔案路徑"
},
"home": {
"title": "Home 控制平面",
"description": "透過 Redis 協議連接 Home 控制平面的選填設定",
"enabled": "啟用 Home",
"enabled_desc": "啟用到 Home 控制平面的出站連線",
"host": "Home 主機",
"port": "Home 連接埠",
"password": "Home 密碼",
"password_placeholder": "留空表示不設定密碼",
"disable_cluster_discovery": "停用叢集探索",
"disable_cluster_discovery_desc": "固定使用設定中的 Home 位址,不切換到 CLUSTER NODES 返回的位址",
"tls_title": "Home TLS",
"tls_description": "Home Redis 連線的 TLS/SNI 與憑證設定",
"tls_enable": "啟用 Home TLS",
"tls_server_name": "TLS Server Name",
"tls_ca_cert": "CA 憑證路徑",
"tls_insecure": "跳過憑證校驗",
"tls_insecure_desc": "僅用於測試自簽端點"
},
"remote": {
"title": "遠端管理",
"description": "遠端存取和控制面板設定",
@@ -1118,6 +1137,8 @@
"allow_remote_desc": "允許從其他主機存取管理介面",
"disable_panel": "停用控制面板",
"disable_panel_desc": "停用內建的 Web 控制面板",
"disable_auto_update_panel": "停用面板自動更新",
"disable_auto_update_panel_desc": "首次缺失時仍可下載,但不再從 GitHub 背景自動更新",
"secret_key": "管理金鑰",
"secret_key_placeholder": "設定管理金鑰",
"panel_repo": "面板儲存庫"
@@ -1137,7 +1158,31 @@
"commercial_mode_desc": "停用高開銷中介軟體以支援高並行",
"logging_to_file": "寫入記錄檔",
"logging_to_file_desc": "將記錄儲存到檔案",
"logs_max_size": "記錄檔大小限制(MB"
"logs_max_size": "記錄檔大小限制(MB",
"error_logs_max_files": "錯誤記錄保留檔案數",
"usage_statistics_enabled": "啟用記憶體用量統計",
"usage_statistics_enabled_desc": "啟用記憶體中的請求用量聚合",
"redis_usage_retention": "Redis 用量佇列保留秒數",
"redis_usage_retention_hint": "RESP 介面 LPOP/RPOP 用量佇列的記憶體保留時間,最大 3600 秒",
"antigravity_signature_cache": "啟用 Antigravity 簽名快取",
"antigravity_signature_cache_desc": "優先使用並校驗快取的 thinking block 簽名",
"antigravity_signature_strict": "嚴格校驗旁路簽名",
"antigravity_signature_strict_desc": "僅在關閉簽名快取時生效,按完整 Claude protobuf 樹校驗"
},
"headers": {
"title": "Header 預設值",
"description": "Claude 與 Codex OAuth 請求在客戶端缺少 Header 時使用的預設值",
"claude_title": "Claude Header Defaults",
"codex_title": "Codex Header Defaults",
"user_agent": "User-Agent",
"package_version": "Package Version",
"runtime_version": "Runtime Version",
"os": "OS",
"arch": "Arch",
"timeout": "Timeout",
"stabilize_device": "穩定設備指紋",
"stabilize_device_desc": "固定 OS/Arch,並按憑證/API Key 穩定軟體指紋",
"beta_features": "Beta Features"
},
"network": {
"title": "網路設定",
@@ -1147,6 +1192,13 @@
"max_retry_credentials": "最大重試憑證數",
"max_retry_credentials_hint": "留空表示不設定;設為 0 表示保留 legacy 行為,並嘗試所有可用憑證。",
"max_retry_interval": "最大重試間隔(秒)",
"auth_auto_refresh_workers": "驗證自動刷新 Worker 數",
"auth_auto_refresh_workers_hint": "大於 0 時覆蓋預設 Worker 數(16",
"disable_image_generation": "停用圖像生成",
"disable_image_generation_hint": "false 為啟用;true 為全部停用;chat 僅停用非圖片端點注入",
"disable_image_generation_false": "false(啟用)",
"disable_image_generation_true": "true(全部停用)",
"disable_image_generation_chat": "chat(僅停用聊天注入)",
"routing_strategy": "路由策略",
"routing_strategy_hint": "選擇憑證選擇策略",
"strategy_round_robin": "輪詢(Round Robin",
@@ -1154,9 +1206,15 @@
"session_affinity_ttl": "會話黏性 TTL",
"force_model_prefix": "強制模型前綴",
"force_model_prefix_desc": "未帶前綴的模型請求只使用無前綴憑證",
"passthrough_headers": "透傳上游回應 Header",
"passthrough_headers_desc": "將過濾後的上游回應 Header 轉發給下游客戶端",
"disable_cooling": "停用冷卻調度",
"disable_cooling_desc": "全域停用驗證/模型失敗後的冷卻視窗",
"session_affinity": "會話黏性路由",
"ws_auth": "WebSocket 驗證",
"ws_auth_desc": "啟用 WebSocket 連線驗證(/v1/ws"
"ws_auth_desc": "啟用 WebSocket 連線驗證(/v1/ws",
"enable_gemini_cli_endpoint": "啟用 Gemini CLI 內部端點",
"enable_gemini_cli_endpoint_desc": "啟用 /v1internal:* 相容端點"
},
"quota": {
"title": "配額回退",
@@ -1225,10 +1283,25 @@
"provider_default": "預設",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_responses": "Responses",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"advanced": "進階",
"hide_advanced": "收起進階",
"from_protocol": "來源協議(from-protocol",
"headers": "請求 Header 匹配",
"header_name": "Header 名稱",
"header_value": "Header 值",
"add_header": "新增 Header",
"match": "必須匹配(match",
"notMatch": "不得匹配(not-match",
"exist": "必須存在(exist",
"notExist": "不得存在(not-exist",
"condition_path": "匹配路徑",
"condition_value": "匹配值",
"add_condition": "新增條件",
"value_type_string": "字串",
"value_type_number": "數字",
"value_type_boolean": "布林",
+75
View File
@@ -1,15 +1,20 @@
export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json';
export type DisableImageGenerationMode = 'false' | 'true' | 'chat';
export type PayloadParamValidationErrorCode =
| 'payload_invalid_number'
| 'payload_invalid_boolean'
| 'payload_invalid_json';
export type VisualConfigFieldPath =
| 'homePort'
| 'port'
| 'errorLogsMaxFiles'
| 'logsMaxTotalSizeMb'
| 'redisUsageQueueRetentionSeconds'
| 'requestRetry'
| 'maxRetryCredentials'
| 'maxRetryInterval'
| 'authAutoRefreshWorkers'
| 'streaming.keepaliveSeconds'
| 'streaming.bootstrapRetries'
| 'streaming.nonstreamKeepaliveInterval';
@@ -27,10 +32,22 @@ export type PayloadParamEntry = {
value: string;
};
export type PayloadHeaderEntry = {
id: string;
name: string;
value: string;
};
export type PayloadModelEntry = {
id: string;
name: string;
protocol?: string;
fromProtocol?: string;
headers?: PayloadHeaderEntry[];
match?: PayloadParamEntry[];
notMatch?: PayloadParamEntry[];
exist?: string[];
notExist?: string[];
};
export type PayloadRule = {
@@ -57,9 +74,19 @@ export type VisualConfigValues = {
tlsEnable: boolean;
tlsCert: string;
tlsKey: string;
homeEnabled: boolean;
homeHost: string;
homePort: string;
homePassword: string;
homeDisableClusterDiscovery: boolean;
homeTlsEnable: boolean;
homeTlsServerName: string;
homeTlsCaCert: string;
homeTlsInsecureSkipVerify: boolean;
rmAllowRemote: boolean;
rmSecretKey: string;
rmDisableControlPanel: boolean;
rmDisableAutoUpdatePanel: boolean;
rmPanelRepo: string;
authDir: string;
apiKeysText: string;
@@ -67,11 +94,18 @@ export type VisualConfigValues = {
commercialMode: boolean;
loggingToFile: boolean;
logsMaxTotalSizeMb: string;
errorLogsMaxFiles: string;
usageStatisticsEnabled: boolean;
redisUsageQueueRetentionSeconds: string;
proxyUrl: string;
forceModelPrefix: boolean;
passthroughHeaders: boolean;
requestRetry: string;
maxRetryCredentials: string;
maxRetryInterval: string;
disableCooling: boolean;
disableImageGeneration: DisableImageGenerationMode;
authAutoRefreshWorkers: string;
quotaSwitchProject: boolean;
quotaSwitchPreviewModel: boolean;
quotaAntigravityCredits: boolean;
@@ -79,6 +113,18 @@ export type VisualConfigValues = {
routingSessionAffinity: boolean;
routingSessionAffinityTTL: string;
wsAuth: boolean;
enableGeminiCliEndpoint: boolean;
antigravitySignatureCacheEnabled: boolean;
antigravitySignatureBypassStrict: boolean;
claudeHeaderUserAgent: string;
claudeHeaderPackageVersion: string;
claudeHeaderRuntimeVersion: string;
claudeHeaderOs: string;
claudeHeaderArch: string;
claudeHeaderTimeout: string;
claudeHeaderStabilizeDeviceProfile: boolean;
codexHeaderUserAgent: string;
codexHeaderBetaFeatures: string;
payloadDefaultRules: PayloadRule[];
payloadDefaultRawRules: PayloadRule[];
payloadOverrideRules: PayloadRule[];
@@ -98,9 +144,19 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
tlsEnable: false,
tlsCert: '',
tlsKey: '',
homeEnabled: false,
homeHost: '',
homePort: '',
homePassword: '',
homeDisableClusterDiscovery: false,
homeTlsEnable: false,
homeTlsServerName: '',
homeTlsCaCert: '',
homeTlsInsecureSkipVerify: false,
rmAllowRemote: false,
rmSecretKey: '',
rmDisableControlPanel: false,
rmDisableAutoUpdatePanel: false,
rmPanelRepo: '',
authDir: '',
apiKeysText: '',
@@ -108,11 +164,18 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
commercialMode: false,
loggingToFile: false,
logsMaxTotalSizeMb: '',
errorLogsMaxFiles: '',
usageStatisticsEnabled: false,
redisUsageQueueRetentionSeconds: '',
proxyUrl: '',
forceModelPrefix: false,
passthroughHeaders: false,
requestRetry: '',
maxRetryCredentials: '',
maxRetryInterval: '',
disableCooling: false,
disableImageGeneration: 'false',
authAutoRefreshWorkers: '',
quotaSwitchProject: true,
quotaSwitchPreviewModel: true,
quotaAntigravityCredits: false,
@@ -120,6 +183,18 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
routingSessionAffinity: false,
routingSessionAffinityTTL: '',
wsAuth: false,
enableGeminiCliEndpoint: false,
antigravitySignatureCacheEnabled: true,
antigravitySignatureBypassStrict: false,
claudeHeaderUserAgent: '',
claudeHeaderPackageVersion: '',
claudeHeaderRuntimeVersion: '',
claudeHeaderOs: '',
claudeHeaderArch: '',
claudeHeaderTimeout: '',
claudeHeaderStabilizeDeviceProfile: false,
codexHeaderUserAgent: '',
codexHeaderBetaFeatures: '',
payloadDefaultRules: [],
payloadDefaultRawRules: [],
payloadOverrideRules: [],