fix(config-editor): preserve comments when saving config.yaml in visual mode

This commit is contained in:
Supra4E8C
2026-02-12 20:26:38 +08:00
parent 2d841c0a2f
commit 4d5bb7e575

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type { import type {
PayloadFilterRule, PayloadFilterRule,
PayloadParamValueType, PayloadParamValueType,
@@ -8,10 +8,6 @@ import type {
} from '@/types/visualConfig'; } from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>; return value as Record<string, unknown>;
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
return keys.join('\n'); return keys.join('\n');
} }
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> { type YamlDocument = ReturnType<typeof parseDocument>;
const existing = asRecord(parent[key]); type YamlPath = string[];
if (existing) return existing;
const next: Record<string, unknown> = {}; function docHas(doc: YamlDocument, path: YamlPath): boolean {
parent[key] = next; return doc.hasIn(path);
return next;
} }
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void { function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
const value = asRecord(parent[key]); const existing = doc.getIn(path, true);
if (!value) return; if (isMap(existing)) return;
if (Object.keys(value).length === 0) delete parent[key]; doc.setIn(path, {});
} }
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void { function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
const value = doc.getIn(path, true);
if (!isMap(value)) return;
if (value.items.length === 0) doc.deleteIn(path);
}
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
if (value) { if (value) {
obj[key] = true; doc.setIn(path, true);
return; return;
} }
if (hasOwn(obj, key)) obj[key] = false; if (docHas(doc, path)) doc.setIn(path, false);
} }
function setString(obj: Record<string, unknown>, key: string, value: unknown): void { function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : ''; const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim(); const trimmed = safe.trim();
if (trimmed !== '') { if (trimmed !== '') {
obj[key] = safe; doc.setIn(path, safe);
return; return;
} }
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
} }
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void { function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : ''; const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim(); const trimmed = safe.trim();
if (trimmed === '') { if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
return; return;
} }
const parsed = Number.parseInt(trimmed, 10); const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
obj[key] = parsed; doc.setIn(path, parsed);
return; return;
} }
if (hasOwn(obj, key)) delete obj[key]; if (docHas(doc, path)) doc.deleteIn(path);
} }
function deepClone<T>(value: T): T { function deepClone<T>(value: T): T {
@@ -351,78 +352,95 @@ export function useVisualConfig() {
const applyVisualChangesToYaml = useCallback( const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => { (currentYaml: string): string => {
try { try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>; const doc = parseDocument(currentYaml);
if (doc.errors.length > 0) return currentYaml;
if (!isMap(doc.contents)) {
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
}
const values = visualValues; const values = visualValues;
setString(parsed, 'host', values.host); setStringInDoc(doc, ['host'], values.host);
setIntFromString(parsed, 'port', values.port); setIntFromStringInDoc(doc, ['port'], values.port);
if ( if (
hasOwn(parsed, 'tls') || docHas(doc, ['tls']) ||
values.tlsEnable || values.tlsEnable ||
values.tlsCert.trim() || values.tlsCert.trim() ||
values.tlsKey.trim() values.tlsKey.trim()
) { ) {
const tls = ensureRecord(parsed, 'tls'); ensureMapInDoc(doc, ['tls']);
setBoolean(tls, 'enable', values.tlsEnable); setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
setString(tls, 'cert', values.tlsCert); setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
setString(tls, 'key', values.tlsKey); setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
deleteIfEmpty(parsed, 'tls'); deleteIfMapEmpty(doc, ['tls']);
} }
if ( if (
hasOwn(parsed, 'remote-management') || docHas(doc, ['remote-management']) ||
values.rmAllowRemote || values.rmAllowRemote ||
values.rmSecretKey.trim() || values.rmSecretKey.trim() ||
values.rmDisableControlPanel || values.rmDisableControlPanel ||
values.rmPanelRepo.trim() values.rmPanelRepo.trim()
) { ) {
const rm = ensureRecord(parsed, 'remote-management'); ensureMapInDoc(doc, ['remote-management']);
setBoolean(rm, 'allow-remote', values.rmAllowRemote); setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey); setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel); setBooleanInDoc(
setString(rm, 'panel-github-repository', values.rmPanelRepo); doc,
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo']; ['remote-management', 'disable-control-panel'],
deleteIfEmpty(parsed, 'remote-management'); values.rmDisableControlPanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
}
deleteIfMapEmpty(doc, ['remote-management']);
} }
setString(parsed, 'auth-dir', values.authDir); setStringInDoc(doc, ['auth-dir'], values.authDir);
if (values.apiKeysText !== baselineValues.apiKeysText) { if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText const apiKeys = values.apiKeysText
.split('\n') .split('\n')
.map((key) => key.trim()) .map((key) => key.trim())
.filter(Boolean); .filter(Boolean);
if (apiKeys.length > 0) { if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys; doc.setIn(['api-keys'], apiKeys);
} else if (hasOwn(parsed, 'api-keys')) { } else if (docHas(doc, ['api-keys'])) {
delete parsed['api-keys']; doc.deleteIn(['api-keys']);
} }
} }
setBoolean(parsed, 'debug', values.debug); setBooleanInDoc(doc, ['debug'], values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode); setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile); setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb); setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled); setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl); setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix); setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry); setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval); setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth); setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) { if (
const quota = ensureRecord(parsed, 'quota-exceeded'); docHas(doc, ['quota-exceeded']) ||
quota['switch-project'] = values.quotaSwitchProject; !values.quotaSwitchProject ||
quota['switch-preview-model'] = values.quotaSwitchPreviewModel; !values.quotaSwitchPreviewModel
deleteIfEmpty(parsed, 'quota-exceeded'); ) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
deleteIfMapEmpty(doc, ['quota-exceeded']);
} }
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') { if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing'); ensureMapInDoc(doc, ['routing']);
routing.strategy = values.routingStrategy; doc.setIn(['routing', 'strategy'], values.routingStrategy);
deleteIfEmpty(parsed, 'routing'); deleteIfMapEmpty(doc, ['routing']);
} }
const keepaliveSeconds = const keepaliveSeconds =
@@ -435,42 +453,55 @@ export function useVisualConfig() {
: ''; : '';
const streamingDefined = const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim(); docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) { if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming'); ensureMapInDoc(doc, ['streaming']);
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds); setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries); setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
deleteIfEmpty(parsed, 'streaming'); deleteIfMapEmpty(doc, ['streaming']);
} }
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval); setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
if ( if (
hasOwn(parsed, 'payload') || docHas(doc, ['payload']) ||
values.payloadDefaultRules.length > 0 || values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 || values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0 values.payloadFilterRules.length > 0
) { ) {
const payload = ensureRecord(parsed, 'payload'); ensureMapInDoc(doc, ['payload']);
if (values.payloadDefaultRules.length > 0) { if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules); doc.setIn(
} else if (hasOwn(payload, 'default')) { ['payload', 'default'],
delete payload.default; serializePayloadRulesForYaml(values.payloadDefaultRules)
);
} else if (docHas(doc, ['payload', 'default'])) {
doc.deleteIn(['payload', 'default']);
} }
if (values.payloadOverrideRules.length > 0) { if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules); doc.setIn(
} else if (hasOwn(payload, 'override')) { ['payload', 'override'],
delete payload.override; serializePayloadRulesForYaml(values.payloadOverrideRules)
);
} else if (docHas(doc, ['payload', 'override'])) {
doc.deleteIn(['payload', 'override']);
} }
if (values.payloadFilterRules.length > 0) { if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules); doc.setIn(
} else if (hasOwn(payload, 'filter')) { ['payload', 'filter'],
delete payload.filter; serializePayloadFilterRulesForYaml(values.payloadFilterRules)
);
} else if (docHas(doc, ['payload', 'filter'])) {
doc.deleteIn(['payload', 'filter']);
} }
deleteIfEmpty(parsed, 'payload'); deleteIfMapEmpty(doc, ['payload']);
} }
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 }); return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch { } catch {
return currentYaml; return currentYaml;
} }