Skip to content

Commit 774d788

Browse files
committed
Restore WebUI config and channel editing
1 parent 54af6b9 commit 774d788

4 files changed

Lines changed: 280 additions & 60 deletions

File tree

server.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ app.get("/api/config", (req, res) => {
100100
});
101101

102102
app.post("/api/config", (_req, res) => {
103-
res.status(405).json({
104-
ok: false,
105-
error: "webui config editing is disabled; edit the backend config file directly",
106-
});
103+
const next = _req.body && typeof _req.body === "object" ? _req.body : {};
104+
for (const key of Object.keys(rawConfig)) {
105+
delete (rawConfig as any)[key];
106+
}
107+
Object.assign(rawConfig, next);
108+
res.json({ ok: true });
107109
});
108110

109111
app.post("/api/rpc/provider", (req, res) => {

src/components/config/useConfigNavigation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ export function useConfigNavigation({
3232
const hotReloadTabKey = '__hot_reload__';
3333

3434
const allTopKeys = useMemo(
35-
() => Object.keys(cfg || {}).filter((key) => key !== 'providers' && typeof (cfg as any)?.[key] === 'object' && (cfg as any)?.[key] !== null),
35+
() => Object.keys(cfg || {}).filter((key) => typeof (cfg as any)?.[key] === 'object' && (cfg as any)?.[key] !== null),
3636
[cfg],
3737
);
3838

3939
const basicTopKeys = useMemo(() => {
40-
const preferred = ['core', 'runtime', 'gateway', 'tools', 'cron', 'agents', 'logging'];
40+
const preferred = ['gateway', 'channels', 'models', 'tools', 'agents', 'cron', 'logging', 'sentinel'];
4141
return preferred.filter((key) => allTopKeys.includes(key));
4242
}, [allTopKeys]);
4343

src/context/ConfigContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ childr
8484
try {
8585
const parsed = JSON.parse(txt);
8686
if (parsed && parsed.config) {
87-
loadedConfig = parsed.config;
8887
const rawConfig = parsed.raw_config && typeof parsed.raw_config === 'object' ? parsed.raw_config : {};
88+
loadedConfig = rawConfig;
8989
if (!configEditing || force) {
9090
setNormalizedCfg(parsed.config || {});
9191
setCfg(rawConfig);

src/pages/Config.tsx

Lines changed: 271 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,289 @@
1-
import React, { useMemo, useState } from 'react';
2-
import { RefreshCw } from 'lucide-react';
1+
import React, { useEffect, useMemo, useState } from 'react';
32
import { useTranslation } from 'react-i18next';
4-
import PageHeader from '../components/layout/PageHeader';
5-
import SectionPanel from '../components/layout/SectionPanel';
6-
import { Button, FixedButton } from '../components/ui/Button';
73
import { useAppContext } from '../context/AppContext';
4+
import { useUI } from '../context/UIContext';
5+
import { TextareaField } from '../components/ui/FormControls';
6+
import { ConfigDiffModal, ConfigHeader, ConfigSidebar, ConfigToolbar } from '../components/config/ConfigPageChrome';
7+
import { buildDiffRows, setPath } from '../components/config/configUtils';
8+
import { useConfigNavigation } from '../components/config/useConfigNavigation';
9+
import { useConfigSaveAction } from '../components/config/useConfigSaveAction';
10+
import RecursiveConfig from '../components/ui/RecursiveConfig';
11+
import { cloneJSON } from '../utils/object';
12+
import ChannelSectionCard from '../components/channel/ChannelSectionCard';
13+
import ChannelFieldRenderer from '../components/channel/ChannelFieldRenderer';
14+
import {
15+
channelDefinitions,
16+
ChannelKey,
17+
getChannelFieldDescription,
18+
getChannelSectionIcon,
19+
parseChannelList,
20+
} from '../components/channel/channelSchema';
21+
22+
type Translate = (key: string, options?: any) => string;
23+
24+
function isHotChannelPath(path: string, hotPaths: string[]) {
25+
if (hotPaths.length === 0) return true;
26+
return hotPaths.some((raw) => {
27+
const candidate = String(raw || '').replace(/\.\*$/, '');
28+
if (!candidate) return false;
29+
return path === candidate || path.startsWith(`${candidate}.`) || candidate.startsWith(`${path}.`);
30+
});
31+
}
32+
33+
function renderChannelCards({
34+
channels,
35+
compiledChannels,
36+
hotOnly,
37+
hotPaths,
38+
setCfg,
39+
t,
40+
}: {
41+
channels: Record<string, any>;
42+
compiledChannels: string[];
43+
hotOnly: boolean;
44+
hotPaths: string[];
45+
setCfg: React.Dispatch<React.SetStateAction<Record<string, any>>>;
46+
t: Translate;
47+
}) {
48+
const knownKeys = new Set<string>([
49+
...Object.keys(channels || {}),
50+
...compiledChannels,
51+
...Object.keys(channelDefinitions),
52+
]);
53+
54+
const channelKeys = Array.from(knownKeys).filter((key): key is ChannelKey => key in channelDefinitions);
55+
if (channelKeys.length === 0) {
56+
return <div className="text-zinc-500 text-sm">{t('configNoGroups')}</div>;
57+
}
58+
59+
return (
60+
<div className="space-y-5">
61+
{channelKeys.map((channelKey) => {
62+
const definition = channelDefinitions[channelKey];
63+
const draft = (channels?.[channelKey] && typeof channels[channelKey] === 'object') ? channels[channelKey] : {};
64+
return (
65+
<ChannelSectionCard
66+
key={channelKey}
67+
icon={React.createElement(getChannelSectionIcon('connection'), { className: 'w-[18px] h-[18px] ui-icon-info' })}
68+
title={t(definition.titleKey)}
69+
hint={t(definition.hintKey)}
70+
>
71+
<div className="space-y-5">
72+
{definition.sections.map((section) => {
73+
const visibleFields = section.fields.filter((field) => {
74+
if (!hotOnly) return true;
75+
return isHotChannelPath(`channels.${channelKey}.${field.key}`, hotPaths);
76+
});
77+
if (visibleFields.length === 0) return null;
78+
const SectionIcon = getChannelSectionIcon(section.id);
79+
return (
80+
<div key={`${channelKey}-${section.id}`} className="space-y-3 rounded-2xl border border-zinc-800/70 bg-zinc-950/25 p-4">
81+
<div className="flex items-start gap-3">
82+
<div className="ui-subpanel flex h-10 w-10 items-center justify-center shrink-0">
83+
<SectionIcon className="w-4 h-4 ui-icon-info" />
84+
</div>
85+
<div className="min-w-0">
86+
<div className="ui-text-primary text-sm font-semibold">{t(section.titleKey)}</div>
87+
<div className="ui-text-muted mt-1 text-xs">{t(section.hintKey)}</div>
88+
</div>
89+
</div>
90+
<div className={`grid grid-cols-1 ${section.columns === 1 ? '' : 'lg:grid-cols-2'} gap-3`}>
91+
{visibleFields.map((field) => (
92+
<ChannelFieldRenderer
93+
key={`${channelKey}-${field.key}`}
94+
channelKey={channelKey}
95+
draft={draft}
96+
field={field}
97+
getDescription={getChannelFieldDescription}
98+
parseList={parseChannelList}
99+
setDraft={(updater) => {
100+
const nextDraft = typeof updater === 'function' ? updater(draft) : updater;
101+
setCfg((current) => setPath(current, `channels.${channelKey}`, nextDraft));
102+
}}
103+
t={t}
104+
/>
105+
))}
106+
</div>
107+
</div>
108+
);
109+
})}
110+
</div>
111+
</ChannelSectionCard>
112+
);
113+
})}
114+
</div>
115+
);
116+
}
8117

9118
const Config: React.FC = () => {
10119
const { t } = useTranslation();
11-
const { cfg, normalizedCfg, cfgRaw, loadConfig, hotReloadFieldDetails } = useAppContext();
120+
const ui = useUI();
121+
const {
122+
cfg,
123+
setCfg,
124+
normalizedCfg,
125+
cfgRaw,
126+
setCfgRaw,
127+
loadConfig,
128+
hotReloadFieldDetails,
129+
setConfigEditing,
130+
setToken,
131+
compiledChannels,
132+
} = useAppContext();
12133
const [showRaw, setShowRaw] = useState(false);
134+
const [basicMode, setBasicMode] = useState(true);
135+
const [hotOnly, setHotOnly] = useState(false);
136+
const [search, setSearch] = useState('');
137+
const [selectedTop, setSelectedTop] = useState('');
138+
const [baseline, setBaseline] = useState<any>(null);
139+
const [showDiff, setShowDiff] = useState(false);
140+
141+
const { activeTop, configLabels, filteredTopKeys, hotReloadTabKey } = useConfigNavigation({
142+
basicMode,
143+
cfg,
144+
hotOnly,
145+
hotReloadFieldDetails,
146+
search,
147+
selectedTop,
148+
t,
149+
});
150+
151+
const { saveConfig } = useConfigSaveAction({
152+
cfg,
153+
normalizedCfg,
154+
cfgRaw,
155+
loadConfig,
156+
setBaseline,
157+
setConfigEditing,
158+
setToken,
159+
setShowDiff,
160+
showRaw,
161+
t,
162+
ui,
163+
});
164+
165+
const currentPayload = useMemo(() => {
166+
if (showRaw) {
167+
try {
168+
return JSON.parse(cfgRaw);
169+
} catch {
170+
return cfg;
171+
}
172+
}
173+
return cfg;
174+
}, [cfg, cfgRaw, showRaw]);
13175

14-
const displayedConfig = useMemo(() => {
15-
if (showRaw) return cfgRaw;
16-
return JSON.stringify(normalizedCfg || {}, null, 2);
17-
}, [cfgRaw, normalizedCfg, showRaw]);
176+
const diffRows = useMemo(() => buildDiffRows(baseline, currentPayload, t('configRoot')), [baseline, currentPayload, t]);
18177

19-
const rawPreview = useMemo(() => {
20-
return JSON.stringify(cfg || {}, null, 2);
21-
}, [cfg]);
178+
const isDirty = useMemo(() => {
179+
if (baseline == null) return false;
180+
return JSON.stringify(baseline) !== JSON.stringify(currentPayload || {});
181+
}, [baseline, currentPayload]);
182+
183+
useEffect(() => {
184+
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
185+
setBaseline(cloneJSON(cfg));
186+
}
187+
}, [baseline, cfg]);
188+
189+
useEffect(() => {
190+
setConfigEditing(isDirty);
191+
return () => setConfigEditing(false);
192+
}, [isDirty, setConfigEditing]);
193+
194+
const hotPaths = useMemo(
195+
() => hotReloadFieldDetails.map((item) => String(item.path || '')).filter(Boolean),
196+
[hotReloadFieldDetails],
197+
);
22198

23199
return (
24200
<div className="p-4 md:p-6 xl:p-8 w-full space-y-4 flex flex-col min-h-full">
25-
<PageHeader
26-
title={t('configuration')}
27-
subtitle={t('configReadonlyNotice', { defaultValue: 'WebUI configuration editing has been removed. Edit local config files directly and restart or reload the gateway.' })}
28-
actions={(
29-
<div className="flex items-center gap-2">
30-
<div className="ui-toolbar-chip flex items-center gap-1 p-1 rounded-xl">
31-
<Button onClick={() => setShowRaw(false)} variant={!showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('form')}</Button>
32-
<Button onClick={() => setShowRaw(true)} variant={showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('rawJson')}</Button>
33-
</div>
34-
<FixedButton onClick={() => void loadConfig(true)} label={t('reload')}>
35-
<RefreshCw className="w-4 h-4" />
36-
</FixedButton>
37-
</div>
38-
)}
201+
<ConfigHeader
202+
onSave={() => void saveConfig()}
203+
onShowForm={() => setShowRaw(false)}
204+
onShowRaw={() => setShowRaw(true)}
205+
showRaw={showRaw}
206+
t={t}
39207
/>
40208

41-
<SectionPanel title={t('configHotFieldsFull')} className="brand-card">
42-
{hotReloadFieldDetails.length === 0 ? (
43-
<div className="text-sm text-zinc-500">-</div>
44-
) : (
45-
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
46-
{hotReloadFieldDetails.map((it) => (
47-
<div key={it.path} className="ui-soft-panel ui-border-subtle p-3 rounded-xl border">
48-
<div className="ui-text-primary font-mono">{it.path}</div>
49-
<div className="ui-text-secondary mt-1">{it.name || ''}{it.description ? ` 路 ${it.description}` : ''}</div>
50-
</div>
51-
))}
209+
<ConfigToolbar
210+
basicMode={basicMode}
211+
hotOnly={hotOnly}
212+
onHotOnlyChange={setHotOnly}
213+
onReload={() => {
214+
void loadConfig(true).then((reloaded) => setBaseline(cloneJSON(reloaded ?? cfg)));
215+
}}
216+
onSearchChange={setSearch}
217+
onShowDiff={() => setShowDiff(true)}
218+
onToggleBasicMode={() => setBasicMode((value) => !value)}
219+
search={search}
220+
t={t}
221+
/>
222+
223+
<div className="flex-1 brand-card ui-border-subtle border rounded-2xl overflow-hidden flex flex-col shadow-sm min-h-[420px]">
224+
{!showRaw ? (
225+
<div className="flex-1 flex min-h-0">
226+
<ConfigSidebar
227+
activeTop={activeTop}
228+
configLabels={configLabels}
229+
filteredTopKeys={filteredTopKeys}
230+
hotReloadTabKey={hotReloadTabKey}
231+
onSelectTop={setSelectedTop}
232+
t={t}
233+
/>
234+
235+
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4">
236+
{activeTop === hotReloadTabKey && (
237+
<div className="space-y-3">
238+
<div className="ui-text-primary text-sm font-semibold">{t('configHotFieldsFull')}</div>
239+
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
240+
{hotReloadFieldDetails.map((item) => (
241+
<div key={item.path} className="ui-soft-panel ui-border-subtle p-2 rounded-xl border">
242+
<div className="ui-text-primary font-mono">{item.path}</div>
243+
<div className="ui-text-secondary">{item.name || ''}{item.description ? ` · ${item.description}` : ''}</div>
244+
</div>
245+
))}
246+
</div>
247+
</div>
248+
)}
249+
250+
{activeTop === 'channels' && renderChannelCards({
251+
channels: (cfg as any)?.channels || {},
252+
compiledChannels,
253+
hotOnly,
254+
hotPaths,
255+
setCfg,
256+
t,
257+
})}
258+
259+
{activeTop && activeTop !== hotReloadTabKey && activeTop !== 'channels' ? (
260+
<RecursiveConfig
261+
data={(cfg as any)?.[activeTop] || {}}
262+
labels={configLabels}
263+
path={activeTop}
264+
hotPaths={hotPaths}
265+
onlyHot={hotOnly}
266+
onChange={(path, value) => setCfg((current) => setPath(current, path, value))}
267+
/>
268+
) : null}
269+
270+
{activeTop !== hotReloadTabKey && activeTop !== 'channels' && !activeTop ? (
271+
<div className="text-zinc-500 text-sm">{t('configNoGroups')}</div>
272+
) : null}
273+
</div>
52274
</div>
275+
) : (
276+
<TextareaField
277+
value={cfgRaw}
278+
onChange={(e) => setCfgRaw(e.target.value)}
279+
monospace
280+
className="flex-1 w-full bg-zinc-950/35 p-6 text-zinc-300 focus:outline-none resize-none border-0 rounded-none"
281+
spellCheck={false}
282+
/>
53283
)}
54-
</SectionPanel>
55-
56-
<SectionPanel title={showRaw ? t('rawJson') : t('configuration')} className="brand-card min-h-[420px]">
57-
<pre className="overflow-auto rounded-2xl border border-zinc-800/70 bg-zinc-950/55 p-4 text-xs leading-6 text-zinc-300 whitespace-pre-wrap break-words">
58-
{displayedConfig}
59-
</pre>
60-
</SectionPanel>
61-
62-
{!showRaw && (
63-
<SectionPanel title={t('rawJson')} className="brand-card">
64-
<pre className="overflow-auto rounded-2xl border border-zinc-800/70 bg-zinc-950/55 p-4 text-xs leading-6 text-zinc-300 whitespace-pre-wrap break-words">
65-
{rawPreview}
66-
</pre>
67-
</SectionPanel>
68-
)}
284+
</div>
285+
286+
{showDiff ? <ConfigDiffModal diffRows={diffRows} onClose={() => setShowDiff(false)} t={t} /> : null}
69287
</div>
70288
);
71289
};

0 commit comments

Comments
 (0)