import React, { useEffect, useMemo, useState } from 'react'; import { Plus, RefreshCw, Save } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import RecursiveConfig from '../components/RecursiveConfig'; function setPath(obj: any, path: string, value: any) { const keys = path.split('.'); const next = JSON.parse(JSON.stringify(obj || {})); let cur = next; for (let i = 0; i < keys.length - 1; i++) { const k = keys[i]; if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {}; cur = cur[k]; } cur[keys[keys.length - 1]] = value; return next; } function parseTagRuleText(raw: string) { const out: Record = {}; for (const line of String(raw || '').split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; const idx = trimmed.indexOf('='); if (idx <= 0) continue; const key = trimmed.slice(0, idx).trim(); const tags = trimmed.slice(idx + 1).split(',').map((item) => item.trim()).filter(Boolean); if (!key || tags.length === 0) continue; out[key] = tags; } return out; } function formatTagRuleText(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return ''; return Object.entries(value as Record) .map(([key, tags]) => `${key}=${Array.isArray(tags) ? tags.join(',') : ''}`) .filter((line) => line !== '=') .join('\n'); } const Config: React.FC = () => { const { t } = useTranslation(); const ui = useUI(); const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, hotReloadFieldDetails, q, setConfigEditing } = useAppContext(); const [showRaw, setShowRaw] = useState(false); const [basicMode, setBasicMode] = useState(true); const [hotOnly, setHotOnly] = useState(false); const [search, setSearch] = useState(''); const [newProxyName, setNewProxyName] = useState(''); const configLabels = useMemo( () => t('configLabels', { returnObjects: true }) as Record, [t] ); const hotPrefixes = useMemo(() => hotReloadFieldDetails.map((x) => String(x.path || '').replace(/\.\*$/, '')).filter(Boolean), [hotReloadFieldDetails]); const hotReloadTabKey = '__hot_reload__'; const allTopKeys = useMemo(() => Object.keys(cfg || {}).filter(k => typeof (cfg as any)?.[k] === 'object' && (cfg as any)?.[k] !== null), [cfg]); const basicTopKeys = useMemo(() => { const preferred = ['gateway', 'providers', 'channels', 'tools', 'cron', 'agents', 'logging']; return preferred.filter((k) => allTopKeys.includes(k)); }, [allTopKeys]); const filteredTopKeys = useMemo(() => { let keys = basicMode ? basicTopKeys : allTopKeys; if (hotOnly) { keys = keys.filter((k) => hotPrefixes.some((p) => p === k || p.startsWith(`${k}.`) || k.startsWith(`${p}.`))); } if (search.trim()) { const s = search.trim().toLowerCase(); keys = keys.filter((k) => k.toLowerCase().includes(s)); } return [hotReloadTabKey, ...keys]; }, [allTopKeys, basicTopKeys, basicMode, hotOnly, search, hotPrefixes]); const [selectedTop, setSelectedTop] = useState(''); const activeTop = filteredTopKeys.includes(selectedTop) ? selectedTop : (filteredTopKeys[0] || ''); const [baseline, setBaseline] = useState(null); const [showDiff, setShowDiff] = useState(false); const currentPayload = useMemo(() => { if (showRaw) { try { return JSON.parse(cfgRaw); } catch { return cfg; } } return cfg; }, [showRaw, cfgRaw, cfg]); const diffRows = useMemo(() => { const out: Array<{ path: string; before: any; after: any }> = []; const walk = (a: any, b: any, p: string) => { const keys = new Set([...(a && typeof a === 'object' ? Object.keys(a) : []), ...(b && typeof b === 'object' ? Object.keys(b) : [])]); if (keys.size === 0) { if (JSON.stringify(a) !== JSON.stringify(b)) out.push({ path: p || t('configRoot'), before: a, after: b }); return; } keys.forEach((k) => { const pa = p ? `${p}.${k}` : k; const av = a ? a[k] : undefined; const bv = b ? b[k] : undefined; const bothObj = av && bv && typeof av === 'object' && typeof bv === 'object' && !Array.isArray(av) && !Array.isArray(bv); if (bothObj) walk(av, bv, pa); else if (JSON.stringify(av) !== JSON.stringify(bv)) out.push({ path: pa, before: av, after: bv }); }); }; walk(baseline || {}, currentPayload || {}, ''); return out; }, [baseline, currentPayload]); const isDirty = useMemo(() => { if (baseline == null) return false; return JSON.stringify(baseline) !== JSON.stringify(currentPayload || {}); }, [baseline, currentPayload]); useEffect(() => { if (baseline == null && cfg && Object.keys(cfg).length > 0) { setBaseline(JSON.parse(JSON.stringify(cfg))); } }, [cfg, baseline]); useEffect(() => { setConfigEditing(isDirty); return () => setConfigEditing(false); }, [isDirty, setConfigEditing]); function updateProxyField(name: string, field: string, value: any) { setCfg((v) => setPath(v, `providers.proxies.${name}.${field}`, value)); } function updateGatewayP2PField(field: string, value: any) { setCfg((v) => setPath(v, `gateway.nodes.p2p.${field}`, value)); } function updateGatewayDispatchField(field: string, value: any) { setCfg((v) => setPath(v, `gateway.nodes.dispatch.${field}`, value)); } function updateGatewayArtifactsField(field: string, value: any) { setCfg((v) => setPath(v, `gateway.nodes.artifacts.${field}`, value)); } function updateGatewayIceServer(index: number, field: string, value: any) { setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {}; if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {}; if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {}; if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = []; if (!next.gateway.nodes.p2p.ice_servers[index] || typeof next.gateway.nodes.p2p.ice_servers[index] !== 'object') { next.gateway.nodes.p2p.ice_servers[index] = { urls: [], username: '', credential: '' }; } next.gateway.nodes.p2p.ice_servers[index][field] = value; return next; }); } function addGatewayIceServer() { setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {}; if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {}; if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {}; if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = []; next.gateway.nodes.p2p.ice_servers.push({ urls: [], username: '', credential: '' }); return next; }); } function removeGatewayIceServer(index: number) { setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); const iceServers = next?.gateway?.nodes?.p2p?.ice_servers; if (Array.isArray(iceServers)) { iceServers.splice(index, 1); } return next; }); } async function removeProxy(name: string) { const ok = await ui.confirmDialog({ title: t('configDeleteProviderConfirmTitle'), message: t('configDeleteProviderConfirmMessage', { name }), danger: true, confirmText: t('delete'), }); if (!ok) return; setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); if (next?.providers?.proxies && typeof next.providers.proxies === 'object') { delete next.providers.proxies[name]; } return next; }); } function addProxy() { const name = newProxyName.trim(); if (!name) return; setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); if (!next.providers || typeof next.providers !== 'object') next.providers = {}; if (!next.providers.proxies || typeof next.providers.proxies !== 'object' || Array.isArray(next.providers.proxies)) { next.providers.proxies = {}; } if (!next.providers.proxies[name]) { next.providers.proxies[name] = { api_key: '', api_base: '', models: [], responses: { web_search_enabled: false, web_search_context_size: '', file_search_vector_store_ids: [], file_search_max_num_results: 0, include: [], stream_include_usage: false, }, supports_responses_compact: false, auth: 'bearer', timeout_sec: 120, }; } return next; }); setNewProxyName(''); } async function saveConfig() { try { const payload = showRaw ? JSON.parse(cfgRaw) : cfg; const submit = async (confirmRisky: boolean) => { const body = confirmRisky ? { ...payload, confirm_risky: true } : payload; return ui.withLoading(async () => { const r = await fetch(`/webui/api/config${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await r.text(); let data: any = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } return { ok: r.ok, text, data }; }, t('saving')); }; let result = await submit(false); if (!result.ok && result.data?.requires_confirm) { const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : ''; const ok = await ui.confirmDialog({ title: t('configRiskyChangeConfirmTitle'), message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }), danger: true, confirmText: t('saveChanges'), }); if (!ok) return; result = await submit(true); } if (!result.ok) { throw new Error(result.data?.error || result.text || 'save failed'); } await ui.notify({ title: t('saved'), message: t('configSaved') }); setBaseline(JSON.parse(JSON.stringify(payload))); setConfigEditing(false); setShowDiff(false); } catch (e) { await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` }); } } return (

{t('configuration')}

setSearch(e.target.value)} placeholder={t('configSearchPlaceholder')} className="ui-input min-w-[240px] flex-1 px-3 py-2 rounded-xl text-sm" />
{!showRaw ? (
{activeTop === hotReloadTabKey && (
{t('configHotFieldsFull')}
{hotReloadFieldDetails.map((it) => (
{it.path}
{it.name || ''}{it.description ? ` ยท ${it.description}` : ''}
))}
)} {activeTop === 'providers' && !showRaw && (
{t('configProxies')}
setNewProxyName(e.target.value)} placeholder={t('configNewProviderName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" />
{Object.entries(((cfg as any)?.providers?.proxies || {}) as Record).map(([name, p]) => (
{name}
updateProxyField(name, 'api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> updateProxyField(name, 'api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> updateProxyField(name, 'models', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.models')}${t('configCommaSeparatedHint')}`} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
))} {Object.keys(((cfg as any)?.providers?.proxies || {}) as Record).length === 0 && (
{t('configNoCustomProviders')}
)}
)} {activeTop === 'gateway' && !showRaw && (
{t('configNodeP2P')}
{t('configNodeP2PHint')}
{t('configNodeP2PIceServers')}
{Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) && (cfg as any).gateway.nodes.p2p.ice_servers.length > 0 ? ( ((cfg as any).gateway.nodes.p2p.ice_servers as Array).map((server, index) => (
updateGatewayIceServer(index, 'urls', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} placeholder={t('configNodeP2PIceUrlsPlaceholder')} className="md:col-span-3 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> updateGatewayIceServer(index, 'username', e.target.value)} placeholder={t('configNodeP2PIceUsername')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> updateGatewayIceServer(index, 'credential', e.target.value)} placeholder={t('configNodeP2PIceCredential')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
)) ) : (
{t('configNodeP2PIceServersEmpty')}
)}
{t('configNodeDispatch')}
{t('configNodeDispatchHint')}