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 }>;