From 11c2498be68d1b775ab3462fcf762b4be3a75d17 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 02:15:40 +0800 Subject: [PATCH] feat: add visual configuration editor and YAML handling - Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing. - Added types for visual configuration in `visualConfig.ts`. - Enhanced `ConfigPage` to support switching between visual and source editors. - Introduced floating action buttons for save and reload actions. - Updated translations for tab labels in English and Chinese. - Styled the configuration page with new tab and floating action button styles. --- package-lock.json | 17 + package.json | 1 + src/components/config/ConfigSection.tsx | 22 + src/components/config/VisualConfigEditor.tsx | 1100 ++++++++++++++++++ src/hooks/useVisualConfig.ts | 447 +++++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/pages/ConfigPage.module.scss | 168 +++ src/pages/ConfigPage.tsx | 321 +++-- src/types/visualConfig.ts | 105 ++ 10 files changed, 2090 insertions(+), 103 deletions(-) create mode 100644 src/components/config/ConfigSection.tsx create mode 100644 src/components/config/VisualConfigEditor.tsx create mode 100644 src/hooks/useVisualConfig.ts create mode 100644 src/types/visualConfig.ts diff --git a/package-lock.json b/package-lock.json index 775d6e2..2ce56de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react-dom": "^19.2.1", "react-i18next": "^16.4.0", "react-router-dom": "^7.10.1", + "yaml": "^2.8.2", "zustand": "^5.0.9" }, "devDependencies": { @@ -4241,6 +4242,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 52b65e3..b8d6a4d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-dom": "^19.2.1", "react-i18next": "^16.4.0", "react-router-dom": "^7.10.1", + "yaml": "^2.8.2", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/src/components/config/ConfigSection.tsx b/src/components/config/ConfigSection.tsx new file mode 100644 index 0000000..53e1da6 --- /dev/null +++ b/src/components/config/ConfigSection.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import { Card } from '@/components/ui/Card'; + +interface ConfigSectionProps { + title: ReactNode; + description?: ReactNode; + className?: string; +} + +export function ConfigSection({ title, description, className, children }: PropsWithChildren) { + return ( + + {description && ( +

+ {description} +

+ )} + {children} +
+ ); +} + diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx new file mode 100644 index 0000000..a4e76d5 --- /dev/null +++ b/src/components/config/VisualConfigEditor.tsx @@ -0,0 +1,1100 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { IconChevronDown } from '@/components/ui/icons'; +import { ConfigSection } from '@/components/config/ConfigSection'; +import type { + PayloadFilterRule, + PayloadModelEntry, + PayloadParamEntry, + PayloadParamValueType, + PayloadRule, + VisualConfigValues, +} from '@/types/visualConfig'; +import { makeClientId } from '@/types/visualConfig'; +import { + VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS, + VISUAL_CONFIG_PROTOCOL_OPTIONS, +} from '@/hooks/useVisualConfig'; +import { maskApiKey } from '@/utils/format'; +import { isValidApiKeyCharset } from '@/utils/validation'; + +interface VisualConfigEditorProps { + values: VisualConfigValues; + disabled?: boolean; + onChange: (values: Partial) => void; +} + +type ToggleRowProps = { + title: string; + description?: string; + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; +}; + +function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRowProps) { + return ( +
+
+
{title}
+ {description && ( +
+ {description} +
+ )} +
+ +
+ ); +} + +function SectionGrid({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Divider() { + return
; +} + +type ToastSelectOption = { value: string; label: string }; + +function ToastSelect({ + value, + options, + disabled, + ariaLabel, + onChange, +}: { + value: string; + options: ReadonlyArray; + disabled?: boolean; + ariaLabel: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find((opt) => opt.value === value) ?? options[0]; + + useEffect(() => { + if (!open) return; + const handleClickOutside = (event: MouseEvent) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + return ( +
+ + + {open && !disabled && ( +
+ {options.map((opt) => { + const active = opt.value === value; + return ( + + ); + })} +
+ )} +
+ ); +} + +function ApiKeysCardEditor({ + value, + disabled, + onChange, +}: { + value: string; + disabled?: boolean; + onChange: (nextValue: string) => void; +}) { + const apiKeys = useMemo( + () => + value + .split('\n') + .map((key) => key.trim()) + .filter(Boolean), + [value] + ); + + const [modalOpen, setModalOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [formError, setFormError] = useState(''); + + const openAddModal = () => { + setEditingIndex(null); + setInputValue(''); + setFormError(''); + setModalOpen(true); + }; + + const openEditModal = (index: number) => { + setEditingIndex(index); + setInputValue(apiKeys[index] ?? ''); + setFormError(''); + setModalOpen(true); + }; + + const closeModal = () => { + setModalOpen(false); + setInputValue(''); + setEditingIndex(null); + setFormError(''); + }; + + const updateApiKeys = (nextKeys: string[]) => { + onChange(nextKeys.join('\n')); + }; + + const handleDelete = (index: number) => { + updateApiKeys(apiKeys.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + const trimmed = inputValue.trim(); + if (!trimmed) { + setFormError('请输入 API 密钥'); + return; + } + if (!isValidApiKeyCharset(trimmed)) { + setFormError('API 密钥包含无效字符'); + return; + } + + const nextKeys = + editingIndex === null + ? [...apiKeys, trimmed] + : apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key)); + updateApiKeys(nextKeys); + closeModal(); + }; + + return ( +
+
+ + +
+ + {apiKeys.length === 0 ? ( +
+ 暂无 API 密钥 +
+ ) : ( +
+ {apiKeys.map((key, index) => ( +
+
+
#{index + 1}
+
API Key
+
{maskApiKey(String(key || ''))}
+
+
+ + +
+
+ ))} +
+ )} + +
每个条目代表一个 API 密钥(与 “API 密钥管理” 页面样式一致)
+ + + + + + } + > + setInputValue(e.target.value)} + disabled={disabled} + error={formError || undefined} + hint="此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口" + /> + +
+ ); +} + +function StringListEditor({ + value, + disabled, + placeholder, + onChange, +}: { + value: string[]; + disabled?: boolean; + placeholder?: string; + onChange: (next: string[]) => void; +}) { + const items = value.length ? value : []; + + const updateItem = (index: number, nextValue: string) => + onChange(items.map((item, i) => (i === index ? nextValue : item))); + const addItem = () => onChange([...items, '']); + const removeItem = (index: number) => onChange(items.filter((_, i) => i !== index)); + + return ( +
+ {items.map((item, index) => ( +
+ updateItem(index, e.target.value)} + disabled={disabled} + style={{ flex: 1 }} + /> + +
+ ))} +
+ +
+
+ ); +} + +function PayloadRulesEditor({ + value, + disabled, + protocolFirst = false, + onChange, +}: { + value: PayloadRule[]; + disabled?: boolean; + protocolFirst?: boolean; + onChange: (next: PayloadRule[]) => void; +}) { + const rules = value.length ? value : []; + + const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); + const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); + + const updateRule = (ruleIndex: number, patch: Partial) => + onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule))); + + const addModel = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined }; + updateRule(ruleIndex, { models: [...rule.models, nextModel] }); + }; + + const removeModel = (ruleIndex: number, modelIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) }); + }; + + const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)), + }); + }; + + const addParam = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextParam: PayloadParamEntry = { + id: makeClientId(), + path: '', + valueType: 'string', + value: '', + }; + updateRule(ruleIndex, { params: [...rule.params, nextParam] }); + }; + + const removeParam = (ruleIndex: number, paramIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) }); + }; + + const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)), + }); + }; + + const getValuePlaceholder = (valueType: PayloadParamValueType) => { + switch (valueType) { + case 'string': + return '字符串值'; + case 'number': + return '数字值 (如 0.7)'; + case 'boolean': + return 'true 或 false'; + case 'json': + return 'JSON 值'; + default: + return '值'; + } + }; + + return ( +
+ {rules.map((rule, ruleIndex) => ( +
+
+
规则 {ruleIndex + 1}
+ +
+ +
+
适用模型
+ {(rule.models.length ? rule.models : []).map((model, modelIndex) => ( +
+ {protocolFirst ? ( + <> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + ) : ( + <> + updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + + )} + +
+ ))} +
+ +
+
+ +
+
参数设置
+ {(rule.params.length ? rule.params : []).map((param, paramIndex) => ( +
+ updateParam(ruleIndex, paramIndex, { path: e.target.value })} + disabled={disabled} + /> + + updateParam(ruleIndex, paramIndex, { valueType: nextValue as PayloadParamValueType }) + } + /> + updateParam(ruleIndex, paramIndex, { value: e.target.value })} + disabled={disabled} + /> + +
+ ))} +
+ +
+
+
+ ))} + + {rules.length === 0 && ( +
+ 暂无规则 +
+ )} + +
+ +
+
+ ); +} + +function PayloadFilterRulesEditor({ + value, + disabled, + onChange, +}: { + value: PayloadFilterRule[]; + disabled?: boolean; + onChange: (next: PayloadFilterRule[]) => void; +}) { + const rules = value.length ? value : []; + + const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]); + const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex)); + + const updateRule = (ruleIndex: number, patch: Partial) => + onChange(rules.map((rule, i) => (i === ruleIndex ? { ...rule, ...patch } : rule))); + + const addModel = (ruleIndex: number) => { + const rule = rules[ruleIndex]; + const nextModel: PayloadModelEntry = { id: makeClientId(), name: '', protocol: undefined }; + updateRule(ruleIndex, { models: [...rule.models, nextModel] }); + }; + + const removeModel = (ruleIndex: number, modelIndex: number) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) }); + }; + + const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial) => { + const rule = rules[ruleIndex]; + updateRule(ruleIndex, { + models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)), + }); + }; + + return ( +
+ {rules.map((rule, ruleIndex) => ( +
+
+
规则 {ruleIndex + 1}
+ +
+ +
+
适用模型
+ {rule.models.map((model, modelIndex) => ( +
+ updateModel(ruleIndex, modelIndex, { name: e.target.value })} + disabled={disabled} + /> + + updateModel(ruleIndex, modelIndex, { + protocol: (nextValue || undefined) as PayloadModelEntry['protocol'], + }) + } + /> + +
+ ))} +
+ +
+
+ +
+
移除参数
+ updateRule(ruleIndex, { params })} + /> +
+
+ ))} + +
+ +
+
+ ); +} + +export function VisualConfigEditor({ values, disabled = false, onChange }: VisualConfigEditorProps) { + const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; + const isNonstreamKeepaliveDisabled = + values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; + + return ( +
+ + + onChange({ host: e.target.value })} + disabled={disabled} + /> + onChange({ port: e.target.value })} + disabled={disabled} + /> + + + + +
+ onChange({ tlsEnable })} + /> + {values.tlsEnable && ( + <> + + + onChange({ tlsCert: e.target.value })} + disabled={disabled} + /> + onChange({ tlsKey: e.target.value })} + disabled={disabled} + /> + + + )} +
+
+ + +
+ onChange({ rmAllowRemote })} + /> + onChange({ rmDisableControlPanel })} + /> + + onChange({ rmSecretKey: e.target.value })} + disabled={disabled} + /> + onChange({ rmPanelRepo: e.target.value })} + disabled={disabled} + /> + +
+
+ + +
+ onChange({ authDir: e.target.value })} + disabled={disabled} + hint="存放认证文件的目录路径(支持 ~)" + /> + onChange({ apiKeysText })} + /> +
+
+ + +
+ + onChange({ debug })} + /> + onChange({ commercialMode })} + /> + onChange({ loggingToFile })} + /> + onChange({ usageStatisticsEnabled })} + /> + + + + onChange({ logsMaxTotalSizeMb: e.target.value })} + disabled={disabled} + /> + onChange({ usageRecordsRetentionDays: e.target.value })} + disabled={disabled} + hint="0 为无限制(不清理)" + /> + +
+
+ + +
+ + onChange({ proxyUrl: e.target.value })} + disabled={disabled} + /> + onChange({ requestRetry: e.target.value })} + disabled={disabled} + /> + onChange({ maxRetryInterval: e.target.value })} + disabled={disabled} + /> +
+ + + onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] }) + } + /> +
选择凭据选择策略
+
+
+ + onChange({ forceModelPrefix })} + /> + onChange({ wsAuth })} + /> +
+
+ + +
+ onChange({ quotaSwitchProject })} + /> + onChange({ quotaSwitchPreviewModel })} + /> +
+
+ + +
+ +
+ +
+ + onChange({ streaming: { ...values.streaming, keepaliveSeconds: e.target.value } }) + } + disabled={disabled} + /> + {isKeepaliveDisabled && ( + + 已禁用 + + )} +
+
设置为 0 或留空表示禁用 keepalive
+
+ onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })} + disabled={disabled} + hint="流式传输启动时(首包前)的重试次数" + /> +
+ + +
+ +
+ + onChange({ + streaming: { ...values.streaming, nonstreamKeepaliveInterval: e.target.value }, + }) + } + disabled={disabled} + /> + {isNonstreamKeepaliveDisabled && ( + + 已禁用 + + )} +
+
+ 非流式响应时每隔 N 秒发送空行以防止空闲超时,设置为 0 或留空表示禁用 +
+
+
+
+
+ + +
+
+
默认规则
+
+ 当请求中未指定参数时,使用这些默认值 +
+ onChange({ payloadDefaultRules })} + /> +
+ +
+
覆盖规则
+
+ 强制覆盖请求中的参数值 +
+ onChange({ payloadOverrideRules })} + /> +
+ +
+
过滤规则
+
+ 通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数(Request Sanitization) +
+ onChange({ payloadFilterRules })} + /> +
+
+
+
+ ); +} diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts new file mode 100644 index 0000000..1f7ed30 --- /dev/null +++ b/src/hooks/useVisualConfig.ts @@ -0,0 +1,447 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { + PayloadFilterRule, + PayloadParamValueType, + PayloadRule, + VisualConfigValues, +} from '@/types/visualConfig'; +import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; + +function hasOwn(obj: unknown, key: string): obj is Record { + return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key); +} + +function asRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function extractApiKeyValue(raw: unknown): string | null { + if (typeof raw === 'string') { + const trimmed = raw.trim(); + return trimmed ? trimmed : null; + } + + const record = asRecord(raw); + if (!record) return null; + + const candidates = [record['api-key'], record.apiKey, record.key, record.Key]; + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const trimmed = candidate.trim(); + if (trimmed) return trimmed; + } + } + + return null; +} + +function parseApiKeysText(raw: unknown): string { + if (!Array.isArray(raw)) return ''; + + const keys: string[] = []; + for (const item of raw) { + const key = extractApiKeyValue(item); + if (key) keys.push(key); + } + return keys.join('\n'); +} + +function ensureRecord(parent: Record, key: string): Record { + const existing = asRecord(parent[key]); + if (existing) return existing; + const next: Record = {}; + parent[key] = next; + return next; +} + +function deleteIfEmpty(parent: Record, key: string): void { + const value = asRecord(parent[key]); + if (!value) return; + if (Object.keys(value).length === 0) delete parent[key]; +} + +function setBoolean(obj: Record, key: string, value: boolean): void { + if (value) { + obj[key] = true; + return; + } + if (hasOwn(obj, key)) obj[key] = false; +} + +function setString(obj: Record, key: string, value: unknown): void { + const safe = typeof value === 'string' ? value : ''; + const trimmed = safe.trim(); + if (trimmed !== '') { + obj[key] = safe; + return; + } + if (hasOwn(obj, key)) delete obj[key]; +} + +function setIntFromString(obj: Record, key: string, value: unknown): void { + const safe = typeof value === 'string' ? value : ''; + const trimmed = safe.trim(); + if (trimmed === '') { + if (hasOwn(obj, key)) delete obj[key]; + return; + } + + const parsed = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsed)) { + obj[key] = parsed; + return; + } + + if (hasOwn(obj, key)) delete obj[key]; +} + +function deepClone(value: T): T { + if (typeof structuredClone === 'function') return structuredClone(value); + return JSON.parse(JSON.stringify(value)) as T; +} + +function parsePayloadRules(rules: unknown): PayloadRule[] { + if (!Array.isArray(rules)) return []; + + return rules.map((rule, index) => ({ + id: `payload-rule-${index}`, + models: Array.isArray((rule as any)?.models) + ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ + id: `model-${index}-${modelIndex}`, + name: typeof model === 'string' ? model : model?.name || '', + protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, + })) + : [], + params: (rule as any)?.params + ? Object.entries((rule as any).params as Record).map(([path, value], pIndex) => ({ + id: `param-${index}-${pIndex}`, + path, + valueType: + typeof value === 'number' + ? 'number' + : typeof value === 'boolean' + ? 'boolean' + : typeof value === 'object' + ? 'json' + : 'string', + value: String(value), + })) + : [], + })); +} + +function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] { + if (!Array.isArray(rules)) return []; + + return rules.map((rule, index) => ({ + id: `payload-filter-rule-${index}`, + models: Array.isArray((rule as any)?.models) + ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ + id: `filter-model-${index}-${modelIndex}`, + name: typeof model === 'string' ? model : model?.name || '', + protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, + })) + : [], + params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [], + })); +} + +function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] { + return rules + .map((rule) => { + const models = (rule.models || []) + .filter((m) => m.name?.trim()) + .map((m) => { + const obj: Record = { name: m.name.trim() }; + if (m.protocol) obj.protocol = m.protocol; + return obj; + }); + + const params: Record = {}; + for (const param of rule.params || []) { + if (!param.path?.trim()) continue; + let value: any = 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; + } + + return { models, params }; + }) + .filter((rule) => rule.models.length > 0); +} + +function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] { + return rules + .map((rule) => { + const models = (rule.models || []) + .filter((m) => m.name?.trim()) + .map((m) => { + const obj: Record = { name: m.name.trim() }; + if (m.protocol) obj.protocol = m.protocol; + return obj; + }); + + const params = (Array.isArray(rule.params) ? rule.params : []) + .map((path) => String(path).trim()) + .filter(Boolean); + + return { models, params }; + }) + .filter((rule) => rule.models.length > 0); +} + +export function useVisualConfig() { + const [visualValues, setVisualValuesState] = useState({ + ...DEFAULT_VISUAL_VALUES, + }); + + const baselineValues = useRef({ ...DEFAULT_VISUAL_VALUES }); + + const visualDirty = useMemo(() => { + return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current); + }, [visualValues]); + + const loadVisualValuesFromYaml = useCallback((yamlContent: string) => { + try { + const parsed: any = parseYaml(yamlContent) || {}; + + const newValues: VisualConfigValues = { + host: parsed.host || '', + port: String(parsed.port || ''), + + tlsEnable: Boolean(parsed.tls?.enable), + tlsCert: parsed.tls?.cert || '', + tlsKey: parsed.tls?.key || '', + + rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']), + rmSecretKey: parsed['remote-management']?.['secret-key'] || '', + rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']), + rmPanelRepo: + parsed['remote-management']?.['panel-github-repository'] ?? + parsed['remote-management']?.['panel-repo'] ?? + '', + + authDir: parsed['auth-dir'] || '', + apiKeysText: parseApiKeysText(parsed['api-keys']), + + debug: Boolean(parsed.debug), + commercialMode: Boolean(parsed['commercial-mode']), + loggingToFile: Boolean(parsed['logging-to-file']), + logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] || ''), + usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), + usageRecordsRetentionDays: String(parsed['usage-records-retention-days'] ?? ''), + + proxyUrl: parsed['proxy-url'] || '', + forceModelPrefix: Boolean(parsed['force-model-prefix']), + requestRetry: String(parsed['request-retry'] || ''), + maxRetryInterval: String(parsed['max-retry-interval'] || ''), + wsAuth: Boolean(parsed['ws-auth']), + + quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true), + quotaSwitchPreviewModel: Boolean( + parsed['quota-exceeded']?.['switch-preview-model'] ?? true + ), + + routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first', + + payloadDefaultRules: parsePayloadRules(parsed.payload?.default), + payloadOverrideRules: parsePayloadRules(parsed.payload?.override), + payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter), + + streaming: { + keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''), + bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''), + nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''), + }, + }; + + setVisualValuesState(newValues); + baselineValues.current = deepClone(newValues); + } catch { + setVisualValuesState({ ...DEFAULT_VISUAL_VALUES }); + baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES); + } + }, []); + + const applyVisualChangesToYaml = useCallback( + (currentYaml: string): string => { + try { + const parsed = (parseYaml(currentYaml) || {}) as Record; + const values = visualValues; + + setString(parsed, 'host', values.host); + setIntFromString(parsed, 'port', values.port); + + if ( + hasOwn(parsed, 'tls') || + values.tlsEnable || + values.tlsCert.trim() || + values.tlsKey.trim() + ) { + const tls = ensureRecord(parsed, 'tls'); + setBoolean(tls, 'enable', values.tlsEnable); + setString(tls, 'cert', values.tlsCert); + setString(tls, 'key', values.tlsKey); + deleteIfEmpty(parsed, 'tls'); + } + + if ( + hasOwn(parsed, 'remote-management') || + values.rmAllowRemote || + values.rmSecretKey.trim() || + values.rmDisableControlPanel || + values.rmPanelRepo.trim() + ) { + const rm = ensureRecord(parsed, 'remote-management'); + setBoolean(rm, 'allow-remote', values.rmAllowRemote); + setString(rm, 'secret-key', values.rmSecretKey); + setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel); + setString(rm, 'panel-github-repository', values.rmPanelRepo); + if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo']; + deleteIfEmpty(parsed, 'remote-management'); + } + + setString(parsed, 'auth-dir', values.authDir); + if (values.apiKeysText !== baselineValues.current.apiKeysText) { + const apiKeys = values.apiKeysText + .split('\n') + .map((key) => key.trim()) + .filter(Boolean); + if (apiKeys.length > 0) { + parsed['api-keys'] = apiKeys; + } else if (hasOwn(parsed, 'api-keys')) { + delete parsed['api-keys']; + } + } + + setBoolean(parsed, 'debug', values.debug); + + setBoolean(parsed, 'commercial-mode', values.commercialMode); + setBoolean(parsed, 'logging-to-file', values.loggingToFile); + setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb); + setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled); + setIntFromString( + parsed, + 'usage-records-retention-days', + values.usageRecordsRetentionDays + ); + + setString(parsed, 'proxy-url', values.proxyUrl); + setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix); + setIntFromString(parsed, 'request-retry', values.requestRetry); + setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval); + setBoolean(parsed, 'ws-auth', values.wsAuth); + + if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) { + const quota = ensureRecord(parsed, 'quota-exceeded'); + quota['switch-project'] = values.quotaSwitchProject; + quota['switch-preview-model'] = values.quotaSwitchPreviewModel; + deleteIfEmpty(parsed, 'quota-exceeded'); + } + + if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') { + const routing = ensureRecord(parsed, 'routing'); + routing.strategy = values.routingStrategy; + deleteIfEmpty(parsed, 'routing'); + } + + const keepaliveSeconds = + typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : ''; + const bootstrapRetries = + typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : ''; + const nonstreamKeepaliveInterval = + typeof values.streaming?.nonstreamKeepaliveInterval === 'string' + ? values.streaming.nonstreamKeepaliveInterval + : ''; + + const streamingDefined = + hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim(); + if (streamingDefined) { + const streaming = ensureRecord(parsed, 'streaming'); + setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds); + setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries); + deleteIfEmpty(parsed, 'streaming'); + } + + setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval); + + if ( + hasOwn(parsed, 'payload') || + values.payloadDefaultRules.length > 0 || + values.payloadOverrideRules.length > 0 || + values.payloadFilterRules.length > 0 + ) { + const payload = ensureRecord(parsed, 'payload'); + if (values.payloadDefaultRules.length > 0) { + payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules); + } else if (hasOwn(payload, 'default')) { + delete payload.default; + } + if (values.payloadOverrideRules.length > 0) { + payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules); + } else if (hasOwn(payload, 'override')) { + delete payload.override; + } + if (values.payloadFilterRules.length > 0) { + payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules); + } else if (hasOwn(payload, 'filter')) { + delete payload.filter; + } + deleteIfEmpty(parsed, 'payload'); + } + + return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 }); + } catch { + return currentYaml; + } + }, + [visualValues] + ); + + const setVisualValues = useCallback((newValues: Partial) => { + setVisualValuesState((prev) => { + const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues; + if (newValues.streaming) { + next.streaming = { ...prev.streaming, ...newValues.streaming }; + } + return next; + }); + }, []); + + return { + visualValues, + visualDirty, + loadVisualValuesFromYaml, + applyVisualChangesToYaml, + setVisualValues, + }; +} + +export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [ + { value: '', label: '默认' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'claude', label: 'Claude' }, + { value: 'codex', label: 'Codex' }, + { value: 'antigravity', label: 'Antigravity' }, +] as const; + +export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [ + { value: 'string', label: '字符串' }, + { value: 'number', label: '数字' }, + { value: 'boolean', label: '布尔' }, + { value: 'json', label: 'JSON' }, +] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 46fbd2b..bdb9c20 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -833,7 +833,11 @@ "search_button": "Search", "search_no_results": "No results", "search_prev": "Previous", - "search_next": "Next" + "search_next": "Next", + "tabs": { + "visual": "Visual Editor", + "source": "Source Editor" + } }, "quota_management": { "title": "Quota Management", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6bcf788..02658d3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -833,7 +833,11 @@ "search_button": "搜索", "search_no_results": "无结果", "search_prev": "上一个", - "search_next": "下一个" + "search_next": "下一个", + "tabs": { + "visual": "可视化编辑", + "source": "源代码编辑" + } }, "quota_management": { "title": "配额管理", diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index aa5abb0..bf4498f 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -6,6 +6,7 @@ display: flex; flex-direction: column; overflow-y: auto; + padding-bottom: calc(var(--config-action-bar-height, 0px) + #{$spacing-lg}); } .pageTitle { @@ -21,6 +22,49 @@ margin: 0 0 $spacing-xl 0; } +.tabBar { + display: flex; + gap: $spacing-xs; + margin-bottom: $spacing-lg; + border-bottom: 1px solid var(--border-color); + overflow-x: auto; +} + +.tabItem { + @include button-reset; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease; + + &:hover:not(:disabled) { + color: var(--text-primary); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:focus, + &:focus-visible { + outline: none; + box-shadow: none; + } +} + +.tabActive { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + .content { display: flex; flex-direction: column; @@ -242,6 +286,130 @@ } } +.floatingActionContainer { + position: fixed; + left: 50%; + bottom: calc(16px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 50; + pointer-events: auto; + width: fit-content; + max-width: calc(100vw - 24px); +} + +.floatingActionList { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 999px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + max-width: inherit; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.floatingStatus { + font-size: 12px; + font-weight: 600; + padding: 6px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.06); + text-align: center; + max-width: min(360px, 52vw); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.floatingActionButton { + @include button-reset; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 999px; + cursor: pointer; + color: var(--text-primary); + transition: background-color 0.2s ease, transform 0.15s ease; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); + transform: scale(1.08); + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } +} + +.dirtyDot { + position: absolute; + top: 9px; + right: 9px; + width: 8px; + height: 8px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); +} + +:global([data-theme='dark']) { + .floatingActionList { + background: rgba(30, 30, 30, 0.7); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } + + .floatingStatus { + background: rgba(255, 255, 255, 0.08); + } + + .floatingActionButton { + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + } + } +} + +@media (max-width: 1200px) { + .floatingActionContainer { + bottom: calc(12px + env(safe-area-inset-bottom)); + max-width: calc(100vw - 16px); + } + + .floatingActionList { + gap: 6px; + padding: 8px 10px; + } + + .floatingStatus { + display: none; + } + + .floatingActionButton { + width: 40px; + height: 40px; + flex: 0 0 auto; + } +} + @media (max-height: 820px) { .pageTitle { font-size: 24px; diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 58d597b..a2b711a 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { yaml } from '@codemirror/lang-yaml'; import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; @@ -7,17 +8,35 @@ import { keymap } from '@codemirror/view'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons'; +import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons'; +import { VisualConfigEditor } from '@/components/config/VisualConfigEditor'; +import { useVisualConfig } from '@/hooks/useVisualConfig'; import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores'; import { configFileApi } from '@/services/api/configFile'; import styles from './ConfigPage.module.scss'; +type ConfigEditorTab = 'visual' | 'source'; + export function ConfigPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const { + visualValues, + visualDirty, + loadVisualValuesFromYaml, + applyVisualChangesToYaml, + setVisualValues + } = useVisualConfig(); + + const [activeTab, setActiveTab] = useState(() => { + const saved = localStorage.getItem('config-management:tab'); + if (saved === 'visual' || saved === 'source') return saved; + return 'visual'; + }); + const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -31,8 +50,10 @@ export function ConfigPage() { const editorRef = useRef(null); const floatingControlsRef = useRef(null); const editorWrapperRef = useRef(null); + const floatingActionsRef = useRef(null); const disableControls = connectionStatus !== 'connected'; + const isDirty = dirty || visualDirty; const loadConfig = useCallback(async () => { setLoading(true); @@ -41,13 +62,14 @@ export function ConfigPage() { const data = await configFileApi.fetchConfigYaml(); setContent(data); setDirty(false); + loadVisualValuesFromYaml(data); } catch (err: unknown) { const message = err instanceof Error ? err.message : t('notification.refresh_failed'); setError(message); } finally { setLoading(false); } - }, [t]); + }, [loadVisualValuesFromYaml, t]); useEffect(() => { loadConfig(); @@ -56,8 +78,11 @@ export function ConfigPage() { const handleSave = async () => { setSaving(true); try { - await configFileApi.saveConfigYaml(content); + const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content; + await configFileApi.saveConfigYaml(nextContent); setDirty(false); + setContent(nextContent); + loadVisualValuesFromYaml(nextContent); showNotification(t('config_management.save_success'), 'success'); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; @@ -72,6 +97,23 @@ export function ConfigPage() { setDirty(true); }, []); + const handleTabChange = useCallback((tab: ConfigEditorTab) => { + if (tab === activeTab) return; + + if (tab === 'source') { + const nextContent = applyVisualChangesToYaml(content); + if (nextContent !== content) { + setContent(nextContent); + setDirty(true); + } + } else { + loadVisualValuesFromYaml(content); + } + + setActiveTab(tab); + localStorage.setItem('config-management:tab', tab); + }, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]); + // Search functionality const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => { if (!query || !editorRef.current?.view) return; @@ -173,6 +215,8 @@ export function ConfigPage() { // Keep floating controls from covering editor content by syncing its height to a CSS variable. useLayoutEffect(() => { + if (activeTab !== 'source') return; + const controlsEl = floatingControlsRef.current; const wrapperEl = editorWrapperRef.current; if (!controlsEl || !wrapperEl) return; @@ -192,6 +236,31 @@ export function ConfigPage() { ro?.disconnect(); window.removeEventListener('resize', updatePadding); }; + }, [activeTab]); + + // Keep bottom floating actions from covering page content by syncing its height to a CSS variable. + useLayoutEffect(() => { + if (typeof window === 'undefined') return; + + const actionsEl = floatingActionsRef.current; + if (!actionsEl) return; + + const updatePadding = () => { + const height = actionsEl.getBoundingClientRect().height; + document.documentElement.style.setProperty('--config-action-bar-height', `${height}px`); + }; + + updatePadding(); + window.addEventListener('resize', updatePadding); + + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding); + ro?.observe(actionsEl); + + return () => { + ro?.disconnect(); + window.removeEventListener('resize', updatePadding); + document.documentElement.style.removeProperty('--config-action-bar-height'); + }; }, []); // CodeMirror extensions @@ -208,131 +277,181 @@ export function ConfigPage() { if (loading) return t('config_management.status_loading'); if (error) return t('config_management.status_load_failed'); if (saving) return t('config_management.status_saving'); - if (dirty) return t('config_management.status_dirty'); + if (isDirty) return t('config_management.status_dirty'); return t('config_management.status_loaded'); }; const getStatusClass = () => { if (error) return styles.error; - if (dirty) return styles.modified; + if (isDirty) return styles.modified; if (!loading && !saving) return styles.saved; return ''; }; + const floatingActions = ( +
+
+
{getStatusText()}
+ + +
+
+ ); + return (

{t('config_management.title')}

{t('config_management.description')}

+
+ + +
+
- {/* Editor */} {error &&
{error}
} -
- {/* Floating search controls */} -
-
- handleSearchChange(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder={t('config_management.search_placeholder', { - defaultValue: '搜索配置内容...' - })} - disabled={disableControls || loading} - className={styles.searchInput} - rightElement={ -
- {searchQuery && lastSearchedQuery === searchQuery && ( - - {searchResults.total > 0 - ? `${searchResults.current} / ${searchResults.total}` - : t('config_management.search_no_results', { defaultValue: '无结果' })} - - )} - -
- } - /> -
-
- - -
-
- -
+ ) : ( +
+ {/* Floating search controls */} +
+
+ handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t('config_management.search_placeholder', { + defaultValue: '搜索配置内容...' + })} + disabled={disableControls || loading} + className={styles.searchInput} + rightElement={ +
+ {searchQuery && lastSearchedQuery === searchQuery && ( + + {searchResults.total > 0 + ? `${searchResults.current} / ${searchResults.total}` + : t('config_management.search_no_results', { defaultValue: '无结果' })} + + )} + +
+ } + /> +
+
+ + +
+
+ +
+ )} {/* Controls */}
{getStatusText()} -
- - -
+ + {typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
); } diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts new file mode 100644 index 0000000..86efcf9 --- /dev/null +++ b/src/types/visualConfig.ts @@ -0,0 +1,105 @@ +export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json'; + +export type PayloadParamEntry = { + id: string; + path: string; + valueType: PayloadParamValueType; + value: string; +}; + +export type PayloadModelEntry = { + id: string; + name: string; + protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity'; +}; + +export type PayloadRule = { + id: string; + models: PayloadModelEntry[]; + params: PayloadParamEntry[]; +}; + +export type PayloadFilterRule = { + id: string; + models: PayloadModelEntry[]; + params: string[]; +}; + +export interface StreamingConfig { + keepaliveSeconds: string; + bootstrapRetries: string; + nonstreamKeepaliveInterval: string; +} + +export type VisualConfigValues = { + host: string; + port: string; + tlsEnable: boolean; + tlsCert: string; + tlsKey: string; + rmAllowRemote: boolean; + rmSecretKey: string; + rmDisableControlPanel: boolean; + rmPanelRepo: string; + authDir: string; + apiKeysText: string; + debug: boolean; + commercialMode: boolean; + loggingToFile: boolean; + logsMaxTotalSizeMb: string; + usageStatisticsEnabled: boolean; + usageRecordsRetentionDays: string; + proxyUrl: string; + forceModelPrefix: boolean; + requestRetry: string; + maxRetryInterval: string; + quotaSwitchProject: boolean; + quotaSwitchPreviewModel: boolean; + routingStrategy: 'round-robin' | 'fill-first'; + wsAuth: boolean; + payloadDefaultRules: PayloadRule[]; + payloadOverrideRules: PayloadRule[]; + payloadFilterRules: PayloadFilterRule[]; + streaming: StreamingConfig; +}; + +export const makeClientId = () => { + if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +}; + +export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { + host: '', + port: '', + tlsEnable: false, + tlsCert: '', + tlsKey: '', + rmAllowRemote: false, + rmSecretKey: '', + rmDisableControlPanel: false, + rmPanelRepo: '', + authDir: '', + apiKeysText: '', + debug: false, + commercialMode: false, + loggingToFile: false, + logsMaxTotalSizeMb: '', + usageStatisticsEnabled: false, + usageRecordsRetentionDays: '', + proxyUrl: '', + forceModelPrefix: false, + requestRetry: '', + maxRetryInterval: '', + quotaSwitchProject: true, + quotaSwitchPreviewModel: true, + routingStrategy: 'round-robin', + wsAuth: false, + payloadDefaultRules: [], + payloadOverrideRules: [], + payloadFilterRules: [], + streaming: { + keepaliveSeconds: '', + bootstrapRetries: '', + nonstreamKeepaliveInterval: '', + }, +};