webui: add sessions/memory management and show full hot-reload field details

This commit is contained in:
DBT
2026-02-25 18:25:23 +00:00
parent 379a0a2366
commit 98add491be
9 changed files with 5488 additions and 25 deletions

5206
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import Cron from './pages/Cron';
import Nodes from './pages/Nodes';
import Logs from './pages/Logs';
import Skills from './pages/Skills';
import Memory from './pages/Memory';
export default function App() {
return (
@@ -23,6 +24,7 @@ export default function App() {
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="nodes" element={<Nodes />} />
<Route path="memory" element={<Memory />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Globe, Zap } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import NavItem from './NavItem';
@@ -20,6 +20,7 @@ const Sidebar: React.FC = () => {
<NavItem icon={<Settings className="w-5 h-5" />} label={t('config')} to="/config" />
<NavItem icon={<Clock className="w-5 h-5" />} label={t('cronJobs')} to="/cron" />
<NavItem icon={<Server className="w-5 h-5" />} label={t('nodes')} to="/nodes" />
<NavItem icon={<FolderOpen className="w-5 h-5" />} label={t('memory')} to="/memory" />
</nav>
<div className="p-4 border-t border-zinc-800 bg-zinc-900/50">

View File

@@ -24,7 +24,10 @@ interface AppContextType {
refreshCron: () => Promise<void>;
refreshNodes: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshSessions: () => Promise<void>;
loadConfig: () => Promise<void>;
hotReloadFields: string[];
hotReloadFieldDetails: Array<{ path: string; name?: string; description?: string }>;
q: string;
}
@@ -48,16 +51,32 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [sessions, setSessions] = useState<Session[]>([{ key: 'webui:default', title: 'Default' }]);
const [hotReloadFields, setHotReloadFields] = useState<string[]>([]);
const [hotReloadFieldDetails, setHotReloadFieldDetails] = useState<Array<{ path: string; name?: string; description?: string }>>([]);
const q = token ? `?token=${encodeURIComponent(token)}` : '';
const loadConfig = useCallback(async () => {
try {
const r = await fetch(`/webui/api/config${q}`);
const hotQ = q ? `${q}&include_hot_reload_fields=1` : '?include_hot_reload_fields=1';
const r = await fetch(`/webui/api/config${hotQ}`);
if (!r.ok) throw new Error('Failed to load config');
const txt = await r.text();
setCfgRaw(txt);
try { setCfg(JSON.parse(txt)); } catch { setCfg({}); }
try {
const parsed = JSON.parse(txt);
if (parsed && parsed.config) {
setCfg(parsed.config);
setCfgRaw(JSON.stringify(parsed.config, null, 2));
setHotReloadFields(Array.isArray(parsed.hot_reload_fields) ? parsed.hot_reload_fields : []);
setHotReloadFieldDetails(Array.isArray(parsed.hot_reload_field_details) ? parsed.hot_reload_field_details : []);
} else {
setCfg(parsed || {});
setCfgRaw(txt);
}
} catch {
setCfgRaw(txt);
try { setCfg(JSON.parse(txt)); } catch { setCfg({}); }
}
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
@@ -104,9 +123,23 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [q]);
const refreshSessions = useCallback(async () => {
try {
const r = await fetch(`/webui/api/sessions${q}`);
if (!r.ok) throw new Error('Failed to load sessions');
const j = await r.json();
const arr = Array.isArray(j.sessions) ? j.sessions : [];
setSessions(arr.map((s: any) => ({ key: s.key, title: s.key })));
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
console.error(e);
}
}, [q]);
const refreshAll = useCallback(async () => {
await Promise.all([loadConfig(), refreshCron(), refreshNodes(), refreshSkills()]);
}, [loadConfig, refreshCron, refreshNodes, refreshSkills]);
await Promise.all([loadConfig(), refreshCron(), refreshNodes(), refreshSkills(), refreshSessions()]);
}, [loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions]);
useEffect(() => {
refreshAll();
@@ -114,9 +147,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
refreshCron();
refreshNodes();
refreshSkills();
refreshSessions();
}, 10000);
return () => clearInterval(interval);
}, [token, refreshAll, refreshCron, refreshNodes, refreshSkills]);
}, [token, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions]);
return (
<AppContext.Provider value={{
@@ -124,7 +158,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
cfg, setCfg, cfgRaw, setCfgRaw, nodes, setNodes,
cron, setCron, skills, setSkills,
sessions, setSessions,
refreshAll, refreshCron, refreshNodes, refreshSkills, loadConfig, q
refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, loadConfig,
hotReloadFields, hotReloadFieldDetails, q
}}>
{children}
</AppContext.Provider>

View File

@@ -12,6 +12,7 @@ const resources = {
nodes: 'Nodes',
logs: 'Real-time Logs',
skills: 'Skills',
memory: 'Memory',
gatewayStatus: 'Gateway Status',
online: 'Online',
offline: 'Offline',
@@ -154,6 +155,7 @@ const resources = {
nodes: '节点',
logs: '实时日志',
skills: '技能管理',
memory: '记忆文件',
gatewayStatus: '网关状态',
online: '在线',
offline: '离线',

View File

@@ -19,7 +19,7 @@ function setPath(obj: any, path: string, value: any) {
const Config: React.FC = () => {
const { t } = useTranslation();
const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, q } = useAppContext();
const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, hotReloadFieldDetails, q } = useAppContext();
const [showRaw, setShowRaw] = useState(false);
async function saveConfig() {
@@ -53,6 +53,18 @@ const Config: React.FC = () => {
</button>
</div>
<div className="bg-zinc-900/40 border border-zinc-800/80 rounded-2xl p-4">
<div className="text-sm font-semibold text-zinc-300 mb-2"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{hotReloadFieldDetails.map((it) => (
<div key={it.path} className="p-2 rounded bg-zinc-950 border border-zinc-800">
<div className="font-mono text-zinc-200">{it.path}</div>
<div className="text-zinc-400">{it.name || ''}{it.description ? ` · ${it.description}` : ''}</div>
</div>
))}
</div>
</div>
<div className="flex-1 bg-zinc-900/40 border border-zinc-800/80 rounded-2xl overflow-hidden flex flex-col shadow-sm">
{!showRaw ? (
<div className="p-8 overflow-y-auto">

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
import { useAppContext } from '../context/AppContext';
const Memory: React.FC = () => {
const { q } = useAppContext();
const [files, setFiles] = useState<string[]>([]);
const [active, setActive] = useState('');
const [content, setContent] = useState('');
async function loadFiles() {
const r = await fetch(`/webui/api/memory${q}`);
const j = await r.json();
setFiles(Array.isArray(j.files) ? j.files : []);
}
const qp = (k: string, v: string) => `${q}${q ? '&' : '?'}${k}=${encodeURIComponent(v)}`;
async function openFile(path: string) {
const r = await fetch(`/webui/api/memory${qp('path', path)}`);
const j = await r.json();
setActive(path);
setContent(j.content || '');
}
async function saveFile() {
if (!active) return;
await fetch(`/webui/api/memory${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: active, content }),
});
await loadFiles();
}
async function removeFile(path: string) {
await fetch(`/webui/api/memory${qp('path', path)}`, { method: 'DELETE' });
if (active === path) {
setActive('');
setContent('');
}
await loadFiles();
}
async function createFile() {
const name = prompt('memory file name', `note-${Date.now()}.md`);
if (!name) return;
await fetch(`/webui/api/memory${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: name, content: '' }),
});
await loadFiles();
await openFile(name);
}
useEffect(() => {
loadFiles().catch(() => {});
}, [q]);
return (
<div className="flex h-full">
<aside className="w-72 border-r border-zinc-800 p-4 space-y-2 overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Memory Files</h2>
<button onClick={createFile} className="px-2 py-1 rounded bg-zinc-800">+</button>
</div>
{files.map((f) => (
<div key={f} className={`flex items-center justify-between p-2 rounded ${active === f ? 'bg-zinc-800' : 'hover:bg-zinc-900'}`}>
<button className="text-left flex-1" onClick={() => openFile(f)}>{f}</button>
<button className="text-red-400" onClick={() => removeFile(f)}>x</button>
</div>
))}
</aside>
<main className="flex-1 p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{active || 'No file selected'}</h2>
<button onClick={saveFile} className="px-3 py-1 rounded bg-indigo-600">Save</button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[80vh] bg-zinc-900 border border-zinc-800 rounded p-3" />
</main>
</div>
);
};
export default Memory;

View File

@@ -13,7 +13,7 @@ export type CronJob = {
to?: string;
};
export type Cfg = Record<string, any>;
export type View = 'dashboard' | 'chat' | 'config' | 'cron' | 'nodes';
export type View = 'dashboard' | 'chat' | 'config' | 'cron' | 'nodes' | 'memory';
export type Lang = 'en' | 'zh';
export type LogEntry = {