Unify agent topology and subagent memory logging

This commit is contained in:
lpf
2026-03-06 15:14:58 +08:00
parent 86691f75d0
commit cc04d9ab3a
27 changed files with 1408 additions and 791 deletions

View File

@@ -7,7 +7,6 @@ import Dashboard from './pages/Dashboard';
import Chat from './pages/Chat';
import Config from './pages/Config';
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';
@@ -32,7 +31,6 @@ export default function App() {
<Route path="skills" element={<Skills />} />
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="nodes" element={<Nodes />} />
<Route path="memory" element={<Memory />} />
<Route path="task-audit" element={<TaskAudit />} />
<Route path="ekg" element={<EKG />} />

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Workflow, Boxes } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Workflow, Boxes } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import NavItem from './NavItem';
@@ -14,6 +14,7 @@ const Sidebar: React.FC = () => {
items: [
{ icon: <LayoutDashboard className="w-5 h-5" />, label: t('dashboard'), to: '/' },
{ icon: <MessageSquare className="w-5 h-5" />, label: t('chat'), to: '/chat' },
{ icon: <Boxes className="w-5 h-5" />, label: t('subagentsRuntime'), to: '/subagents' },
{ icon: <Terminal className="w-5 h-5" />, label: t('logs'), to: '/logs' },
{ icon: <Hash className="w-5 h-5" />, label: t('logCodes'), to: '/log-codes' },
{ icon: <Zap className="w-5 h-5" />, label: t('skills'), to: '/skills' },
@@ -24,10 +25,8 @@ const Sidebar: React.FC = () => {
items: [
{ icon: <Settings className="w-5 h-5" />, label: t('config'), to: '/config' },
{ icon: <Clock className="w-5 h-5" />, label: t('cronJobs'), to: '/cron' },
{ icon: <Server className="w-5 h-5" />, label: t('nodes'), to: '/nodes' },
{ icon: <FolderOpen className="w-5 h-5" />, label: t('memory'), to: '/memory' },
{ icon: <Bot className="w-5 h-5" />, label: t('subagentProfiles'), to: '/subagent-profiles' },
{ icon: <Boxes className="w-5 h-5" />, label: t('subagentsRuntime'), to: '/subagents' },
{ icon: <Workflow className="w-5 h-5" />, label: t('pipelines'), to: '/pipelines' },
],
},

View File

@@ -14,6 +14,8 @@ interface AppContextType {
setCfgRaw: (raw: string) => void;
nodes: string;
setNodes: (nodes: string) => void;
nodeTrees: string;
setNodeTrees: (trees: string) => void;
cron: CronJob[];
setCron: (cron: CronJob[]) => void;
skills: Skill[];
@@ -53,6 +55,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [cfg, setCfg] = useState<Cfg>({});
const [cfgRaw, setCfgRaw] = useState('{}');
const [nodes, setNodes] = useState('[]');
const [nodeTrees, setNodeTrees] = useState('[]');
const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [clawhubInstalled, setClawhubInstalled] = useState(false);
@@ -99,6 +102,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (!r.ok) throw new Error('Failed to load nodes');
const j = await r.json();
setNodes(JSON.stringify(j.nodes || [], null, 2));
setNodeTrees(JSON.stringify(j.trees || [], null, 2));
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
@@ -167,6 +171,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
useEffect(() => {
refreshAll();
const interval = setInterval(() => {
loadConfig();
refreshCron();
refreshNodes();
refreshSkills();
@@ -174,12 +179,12 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
refreshVersion();
}, 10000);
return () => clearInterval(interval);
}, [token, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]);
}, [token, refreshAll, loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]);
return (
<AppContext.Provider value={{
token, setToken, sidebarOpen, setSidebarOpen, isGatewayOnline, setIsGatewayOnline,
cfg, setCfg, cfgRaw, setCfgRaw, nodes, setNodes,
cfg, setCfg, cfgRaw, setCfgRaw, nodes, setNodes, nodeTrees, setNodeTrees,
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions,
refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion, loadConfig,

View File

@@ -10,6 +10,10 @@ const resources = {
config: 'Config',
cronJobs: 'Cron Jobs',
nodes: 'Nodes',
agentTree: 'Agent Tree',
noAgentTree: 'No agent tree available.',
readonlyMirror: 'Read-only mirror',
localControl: 'Local control',
logs: 'Real-time Logs',
logCodes: 'Log Codes',
skills: 'Skills',
@@ -17,26 +21,32 @@ const resources = {
taskAudit: 'Task Audit',
tasks: 'Tasks',
subagentProfiles: 'Subagent Profiles',
subagentsRuntime: 'Subagents Runtime',
subagentsRuntime: 'Agents',
agentTopology: 'Agent Topology',
agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.',
runningTasks: 'running',
clearFocus: 'Clear Focus',
childrenCount: 'children',
'topologyFilter.all': 'All',
'topologyFilter.running': 'Running',
'topologyFilter.failed': 'Failed',
'topologyFilter.local': 'Local',
'topologyFilter.remote': 'Remote',
noLiveTasks: 'No live tasks',
remoteTasksUnavailable: 'Remote task details are not mirrored yet.',
subagentDetail: 'Subagent Detail',
spawnSubagent: 'Spawn Subagent',
dispatchAndWait: 'Dispatch And Wait',
dispatchReply: 'Dispatch Reply',
mergedResult: 'Merged Result',
configSubagentDraft: 'Config Subagent Draft',
configSubagentDraft: 'Config Subagent',
agentRegistry: 'Agent Registry',
pendingSubagentDrafts: 'Pending Subagent Drafts',
subagentDraftDescription: 'Describe the subagent you want the main agent to create',
generateDraft: 'Generate Draft',
confirmDraft: 'Confirm Draft',
loadDraft: 'Load Draft',
discardDraft: 'Discard Draft',
enableAgent: 'Enable Agent',
disableAgent: 'Disable Agent',
deleteAgent: 'Delete Agent',
deleteAgentConfirm: 'Delete agent "{{id}}" from config.json permanently?',
noRegistryAgents: 'No configured agents.',
noPendingSubagentDrafts: 'No pending subagent drafts.',
saveToConfig: 'Save To Config',
configSubagentSaved: 'Subagent config saved and runtime updated.',
promptFileEditor: 'Prompt File Editor',
@@ -455,6 +465,10 @@ const resources = {
config: '配置',
cronJobs: '定时任务',
nodes: '节点',
agentTree: '代理树',
noAgentTree: '当前没有可用的代理树。',
readonlyMirror: '只读镜像',
localControl: '本地控制',
logs: '实时日志',
logCodes: '日志编号',
skills: '技能管理',
@@ -462,26 +476,32 @@ const resources = {
taskAudit: '任务审计',
tasks: '任务管理',
subagentProfiles: '子代理档案',
subagentsRuntime: '子代理运行态',
subagentsRuntime: 'Agents',
agentTopology: 'Agent 拓扑',
agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。',
runningTasks: '运行中',
clearFocus: '清除聚焦',
childrenCount: '子节点',
'topologyFilter.all': '全部',
'topologyFilter.running': '运行中',
'topologyFilter.failed': '失败',
'topologyFilter.local': '本地',
'topologyFilter.remote': '远端',
noLiveTasks: '当前没有活动任务',
remoteTasksUnavailable: '远端任务细节暂未镜像回来。',
subagentDetail: '子代理详情',
spawnSubagent: '创建子代理任务',
dispatchAndWait: '派发并等待',
dispatchReply: '派发回复',
mergedResult: '汇总结果',
configSubagentDraft: '配置子代理草案',
configSubagentDraft: '配置子代理',
agentRegistry: '代理注册表',
pendingSubagentDrafts: '待确认子代理草案',
subagentDraftDescription: '描述你希望主代理创建的子代理职责',
generateDraft: '生成草案',
confirmDraft: '确认草案',
loadDraft: '载入草案',
discardDraft: '丢弃草案',
loadDraft: '载入配置',
enableAgent: '启用代理',
disableAgent: '停用代理',
deleteAgent: '删除代理',
deleteAgentConfirm: '确认从 config.json 中永久删除代理 "{{id}}" 吗?',
noRegistryAgents: '当前没有已配置代理。',
noPendingSubagentDrafts: '当前没有待确认的子代理草案。',
saveToConfig: '写入配置',
configSubagentSaved: '子代理配置已写入并刷新运行态。',
promptFileEditor: '提示词文件编辑器',

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { RefreshCw, Server } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
const Nodes: React.FC = () => {
const { t } = useTranslation();
const { nodes, refreshNodes } = useAppContext();
return (
<div className="p-8 max-w-7xl mx-auto space-y-8 h-full flex flex-col">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('nodes')}</h1>
<button onClick={refreshNodes} className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors">
<RefreshCw className="w-4 h-4" /> {t('refresh')}
</button>
</div>
<div className="flex-1 bg-zinc-900/40 border border-zinc-800/80 rounded-2xl p-6 flex flex-col shadow-sm overflow-hidden">
<div className="flex-1 bg-zinc-950/80 rounded-xl border border-zinc-800/50 p-4 overflow-auto">
{(() => {
try {
const parsedNodes = JSON.parse(nodes);
if (!Array.isArray(parsedNodes) || parsedNodes.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 space-y-4">
<Server className="w-12 h-12 opacity-20" />
<p className="text-lg font-medium">{t('noNodes')}</p>
</div>
);
}
return (
<div className="space-y-3">
{parsedNodes.map((node: any, i: number) => (
<div key={node.id || i} className="flex items-center justify-between p-4 bg-zinc-900/50 rounded-xl border border-zinc-800/50 hover:border-zinc-700/50 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${node.online ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} />
<div>
<div className="font-semibold text-zinc-200">{node.name || node.id || `${t('node')} ${i + 1}`}</div>
<div className="text-xs text-zinc-500 font-mono mt-0.5">{node.id}</div>
</div>
</div>
<div className="text-right">
<div className="text-xs font-mono text-zinc-400 bg-zinc-800/50 px-2 py-1 rounded">{node.ip || t('unknownIp')}</div>
<div className="text-[10px] text-zinc-600 mt-1 font-mono uppercase tracking-widest">v{node.version || '0.0.0'}</div>
</div>
</div>
))}
</div>
);
} catch (e) {
return <pre className="font-mono text-[13px] leading-relaxed text-zinc-400 whitespace-pre-wrap">{nodes}</pre>;
}
})()}
</div>
</div>
</div>
);
};
export default Nodes;

View File

@@ -58,24 +58,14 @@ type AgentMessage = {
created_at?: number;
};
type PendingSubagentDraft = {
session_key?: string;
draft?: {
agent_id?: string;
role?: string;
display_name?: string;
description?: string;
system_prompt?: string;
system_prompt_file?: string;
tool_allowlist?: string[];
routing_keywords?: string[];
};
};
type RegistrySubagent = {
agent_id?: string;
enabled?: boolean;
type?: string;
transport?: string;
node_id?: string;
parent_agent_id?: string;
managed_by?: string;
display_name?: string;
role?: string;
description?: string;
@@ -87,9 +77,165 @@ type RegistrySubagent = {
routing_keywords?: string[];
};
type AgentTreeNode = {
agent_id?: string;
display_name?: string;
role?: string;
type?: string;
transport?: string;
managed_by?: string;
node_id?: string;
enabled?: boolean;
children?: AgentTreeNode[];
};
type NodeTree = {
node_id?: string;
node_name?: string;
online?: boolean;
source?: string;
readonly?: boolean;
root?: {
root?: AgentTreeNode;
};
};
type NodeInfo = {
id?: string;
name?: string;
endpoint?: string;
version?: string;
online?: boolean;
};
type AgentTaskStats = {
total: number;
running: number;
failed: number;
waiting: number;
active: Array<{ id: string; status: string; title: string }>;
};
type GraphCardSpec = {
key: string;
branch: string;
agentID?: string;
transportType?: 'local' | 'remote';
x: number;
y: number;
w: number;
h: number;
kind: 'node' | 'agent';
title: string;
subtitle: string;
meta: string[];
accent: string;
online?: boolean;
clickable?: boolean;
highlighted?: boolean;
dimmed?: boolean;
hidden?: boolean;
onClick?: () => void;
};
type GraphLineSpec = {
x1: number;
y1: number;
x2: number;
y2: number;
dashed?: boolean;
branch: string;
highlighted?: boolean;
dimmed?: boolean;
hidden?: boolean;
};
const cardWidth = 230;
const cardHeight = 112;
const clusterWidth = 350;
const topY = 24;
const mainY = 172;
const childStartY = 334;
const childGap = 132;
function normalizeTitle(value?: string, fallback = '-'): string {
const trimmed = `${value || ''}`.trim();
return trimmed || fallback;
}
function summarizeTask(task?: string, label?: string): string {
const text = normalizeTitle(label || task, '-');
return text.length > 52 ? `${text.slice(0, 49)}...` : text;
}
function buildTaskStats(tasks: SubagentTask[]): Record<string, AgentTaskStats> {
return tasks.reduce<Record<string, AgentTaskStats>>((acc, task) => {
const agentID = normalizeTitle(task.agent_id, '');
if (!agentID) return acc;
if (!acc[agentID]) {
acc[agentID] = { total: 0, running: 0, failed: 0, waiting: 0, active: [] };
}
const item = acc[agentID];
item.total += 1;
if (task.status === 'running') item.running += 1;
if (task.status === 'failed') item.failed += 1;
if (task.waiting_for_reply) item.waiting += 1;
if (task.status === 'running' || task.waiting_for_reply) {
item.active.push({
id: task.id,
status: task.status || '-',
title: summarizeTask(task.task, task.label),
});
}
return acc;
}, {});
}
function GraphCard({ card }: { card: GraphCardSpec }) {
return (
<foreignObject x={card.x} y={card.y} width={card.w} height={card.h}>
<button
type="button"
onClick={card.onClick}
disabled={!card.clickable}
className={`h-full w-full rounded-2xl border text-left p-3 shadow-sm ${
card.clickable ? 'cursor-pointer hover:border-zinc-600' : 'cursor-default'
}`}
style={{
background: card.highlighted ? 'rgba(39,39,42,0.98)' : 'rgba(24,24,27,0.92)',
borderColor: card.highlighted ? 'rgba(251,191,36,0.85)' : 'rgba(63,63,70,0.9)',
boxShadow: card.highlighted
? '0 0 0 1px rgba(251,191,36,0.35), 0 12px 30px rgba(0,0,0,0.28)'
: card.online
? '0 0 0 1px rgba(16,185,129,0.15)'
: undefined,
opacity: card.dimmed ? 0.38 : 1,
transform: card.highlighted ? 'translateY(-2px)' : undefined,
transition: 'all 160ms ease',
}}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-100">{card.title}</div>
<div className="truncate text-[11px] text-zinc-500">{card.subtitle}</div>
</div>
<div className={`h-2.5 w-2.5 rounded-full ${card.accent}`} />
</div>
<div className="mt-3 space-y-1">
{card.meta.slice(0, 4).map((line, idx) => (
<div key={`${card.key}-${idx}`} className="truncate text-[11px] text-zinc-300">
{line}
</div>
))}
</div>
</button>
</foreignObject>
);
}
const Subagents: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
const { q, nodeTrees, nodes } = useAppContext();
const ui = useUI();
const [items, setItems] = useState<SubagentTask[]>([]);
@@ -117,32 +263,27 @@ const Subagents: React.FC = () => {
const [configSystemPromptFile, setConfigSystemPromptFile] = useState('');
const [configToolAllowlist, setConfigToolAllowlist] = useState('');
const [configRoutingKeywords, setConfigRoutingKeywords] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [pendingDrafts, setPendingDrafts] = useState<PendingSubagentDraft[]>([]);
const [registryItems, setRegistryItems] = useState<RegistrySubagent[]>([]);
const [promptFileContent, setPromptFileContent] = useState('');
const [promptFileFound, setPromptFileFound] = useState(false);
const [selectedTopologyBranch, setSelectedTopologyBranch] = useState('');
const [topologyFilter, setTopologyFilter] = useState<'all' | 'running' | 'failed' | 'local' | 'remote'>('all');
const apiPath = '/webui/api/subagents_runtime';
const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`;
const load = async () => {
const [tasksRes, draftsRes, registryRes] = await Promise.all([
const [tasksRes, registryRes] = await Promise.all([
fetch(withAction('list')),
fetch(withAction('pending_drafts')),
fetch(withAction('registry')),
]);
if (!tasksRes.ok) throw new Error(await tasksRes.text());
if (!draftsRes.ok) throw new Error(await draftsRes.text());
if (!registryRes.ok) throw new Error(await registryRes.text());
const j = await tasksRes.json();
const draftsJson = await draftsRes.json();
const registryJson = await registryRes.json();
const arr = Array.isArray(j?.result?.items) ? j.result.items : [];
const draftItems = Array.isArray(draftsJson?.result?.items) ? draftsJson.result.items : [];
const registryItems = Array.isArray(registryJson?.result?.items) ? registryJson.result.items : [];
setItems(arr);
setPendingDrafts(draftItems);
setRegistryItems(registryItems);
if (arr.length === 0) {
setSelectedId('');
@@ -155,7 +296,318 @@ const Subagents: React.FC = () => {
load().catch(() => {});
}, [q]);
useEffect(() => {
const interval = window.setInterval(() => {
load().catch(() => {});
}, 5000);
return () => window.clearInterval(interval);
}, [q]);
const selected = useMemo(() => items.find((x) => x.id === selectedId) || null, [items, selectedId]);
const parsedNodeTrees = useMemo<NodeTree[]>(() => {
try {
const parsed = JSON.parse(nodeTrees);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}, [nodeTrees]);
const parsedNodes = useMemo<NodeInfo[]>(() => {
try {
const parsed = JSON.parse(nodes);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}, [nodes]);
const taskStats = useMemo(() => buildTaskStats(items), [items]);
const recentTaskByAgent = useMemo(() => {
return items.reduce<Record<string, SubagentTask>>((acc, task) => {
const agentID = normalizeTitle(task.agent_id, '');
if (!agentID) return acc;
const existing = acc[agentID];
const currentScore = Math.max(task.updated || 0, task.created || 0);
const existingScore = existing ? Math.max(existing.updated || 0, existing.created || 0) : -1;
if (!existing || currentScore > existingScore) {
acc[agentID] = task;
}
return acc;
}, {});
}, [items]);
const topologyGraph = useMemo(() => {
const localTree = parsedNodeTrees.find((tree) => normalizeTitle(tree.node_id, '') === 'local') || null;
const remoteTrees = parsedNodeTrees.filter((tree) => normalizeTitle(tree.node_id, '') !== 'local');
const localRoot = localTree?.root?.root || {
agent_id: 'main',
display_name: 'Main Agent',
role: 'orchestrator',
type: 'router',
transport: 'local',
enabled: true,
children: registryItems
.filter((item) => item.agent_id && item.agent_id !== 'main' && item.managed_by === 'config.json')
.map((item) => ({
agent_id: item.agent_id,
display_name: item.display_name,
role: item.role,
type: item.type,
transport: item.transport,
enabled: item.enabled,
children: [],
})),
};
const localNode = parsedNodes.find((node) => normalizeTitle(node.id, '') === 'local') || {
id: 'local',
name: 'local',
online: true,
};
const clusterCount = Math.max(1, remoteTrees.length + 1);
const localChildren = Array.isArray(localRoot.children) ? localRoot.children : [];
const remoteChildMax = remoteTrees.reduce((max, tree) => {
const count = Array.isArray(tree.root?.root?.children) ? tree.root?.root?.children?.length || 0 : 0;
return Math.max(max, count);
}, 0);
const maxChildren = Math.max(localChildren.length, remoteChildMax, 1);
const width = clusterCount * clusterWidth + 120;
const height = childStartY + maxChildren * childGap + 30;
const mainClusterX = 56;
const localNodeX = mainClusterX + 20;
const localMainX = mainClusterX + 20;
const localMainCenterX = localMainX + cardWidth / 2;
const localMainCenterY = mainY + cardHeight / 2;
const cards: GraphCardSpec[] = [];
const lines: GraphLineSpec[] = [];
const localBranch = 'local';
const localBranchStats = {
running: 0,
failed: 0,
};
const localNodeCard: GraphCardSpec = {
key: 'node-local',
branch: localBranch,
transportType: 'local',
kind: 'node',
x: localNodeX,
y: topY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(localNode.name, 'local'),
subtitle: normalizeTitle(localNode.id, 'local'),
meta: [
localNode.online ? t('online') : t('offline'),
localNode.endpoint ? `endpoint=${localNode.endpoint}` : 'endpoint=-',
localNode.version ? `version=${localNode.version}` : 'version=-',
`${t('childrenCount')}=${localChildren.length}`,
],
accent: localNode.online ? 'bg-emerald-500' : 'bg-red-500',
online: !!localNode.online,
clickable: true,
onClick: () => setSelectedTopologyBranch(localBranch),
};
const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, active: [] };
const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')];
localBranchStats.running += localMainStats.running;
localBranchStats.failed += localMainStats.failed;
const localMainCard: GraphCardSpec = {
key: 'agent-main',
branch: localBranch,
agentID: normalizeTitle(localRoot.agent_id, 'main'),
transportType: 'local',
kind: 'agent',
x: localMainX,
y: mainY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(localRoot.display_name, 'Main Agent'),
subtitle: `${normalizeTitle(localRoot.agent_id, 'main')} · ${normalizeTitle(localRoot.role, '-')}`,
meta: [
`total=${localMainStats.total} running=${localMainStats.running}`,
`waiting=${localMainStats.waiting} failed=${localMainStats.failed}`,
`transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`,
localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'),
],
accent: 'bg-amber-400',
clickable: true,
onClick: () => {
setSelectedTopologyBranch(localBranch);
if (localMainTask?.id) setSelectedId(localMainTask.id);
},
};
cards.push(localNodeCard, localMainCard);
lines.push({
x1: localNodeCard.x + cardWidth / 2,
y1: localNodeCard.y + cardHeight,
x2: localMainCard.x + cardWidth / 2,
y2: localMainCard.y,
branch: localBranch,
});
localChildren.forEach((child, idx) => {
const childX = mainClusterX + 20;
const childY = childStartY + idx * childGap;
const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, active: [] };
const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')];
localBranchStats.running += stats.running;
localBranchStats.failed += stats.failed;
cards.push({
key: `local-child-${child.agent_id || idx}`,
branch: localBranch,
agentID: normalizeTitle(child.agent_id, ''),
transportType: 'local',
kind: 'agent',
x: childX,
y: childY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(child.display_name, normalizeTitle(child.agent_id, 'agent')),
subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`,
meta: [
`total=${stats.total} running=${stats.running}`,
`waiting=${stats.waiting} failed=${stats.failed}`,
`transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`,
stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'),
],
accent: stats.running > 0 ? 'bg-emerald-500' : stats.failed > 0 ? 'bg-red-500' : 'bg-sky-400',
clickable: true,
onClick: () => {
setSelectedTopologyBranch(localBranch);
if (task?.id) setSelectedId(task.id);
},
});
lines.push({
x1: localMainCenterX,
y1: localMainCenterY,
x2: childX + cardWidth / 2,
y2: childY,
branch: localBranch,
});
});
remoteTrees.forEach((tree, treeIndex) => {
const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`;
const baseX = 56 + (treeIndex + 1) * clusterWidth;
const nodeX = baseX + 20;
const rootX = baseX + 20;
const treeRoot = tree.root?.root;
const remoteNodeCard: GraphCardSpec = {
key: `node-${tree.node_id || treeIndex}`,
branch,
transportType: 'remote',
kind: 'node',
x: nodeX,
y: topY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(tree.node_name, tree.node_id || 'node'),
subtitle: normalizeTitle(tree.node_id, 'node'),
meta: [
tree.online ? t('online') : t('offline'),
normalizeTitle(tree.source, '-'),
tree.readonly ? t('readonlyMirror') : t('localControl'),
`${t('childrenCount')}=${Array.isArray(treeRoot?.children) ? treeRoot?.children?.length || 0 : 0}`,
],
accent: tree.online ? 'bg-emerald-500' : 'bg-red-500',
online: !!tree.online,
clickable: true,
onClick: () => setSelectedTopologyBranch(branch),
};
cards.push(remoteNodeCard);
lines.push({
x1: localMainCenterX,
y1: localMainCenterY,
x2: remoteNodeCard.x,
y2: remoteNodeCard.y + cardHeight / 2,
dashed: true,
branch,
});
if (!treeRoot) return;
const rootCard: GraphCardSpec = {
key: `remote-root-${tree.node_id || treeIndex}`,
branch,
agentID: normalizeTitle(treeRoot.agent_id, ''),
transportType: 'remote',
kind: 'agent',
x: rootX,
y: mainY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(treeRoot.display_name, treeRoot.agent_id || 'main'),
subtitle: `${normalizeTitle(treeRoot.agent_id, '-')} · ${normalizeTitle(treeRoot.role, '-')}`,
meta: [
`transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`,
`source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`,
t('remoteTasksUnavailable'),
],
accent: tree.online ? 'bg-fuchsia-400' : 'bg-zinc-500',
clickable: true,
onClick: () => setSelectedTopologyBranch(branch),
};
cards.push(rootCard);
lines.push({
x1: remoteNodeCard.x + cardWidth / 2,
y1: remoteNodeCard.y + cardHeight,
x2: rootCard.x + cardWidth / 2,
y2: rootCard.y,
branch,
});
const children = Array.isArray(treeRoot.children) ? treeRoot.children : [];
children.forEach((child, idx) => {
const childX = baseX + 20;
const childY = childStartY + idx * childGap;
cards.push({
key: `remote-child-${tree.node_id || treeIndex}-${child.agent_id || idx}`,
branch,
agentID: normalizeTitle(child.agent_id, ''),
transportType: 'remote',
kind: 'agent',
x: childX,
y: childY,
w: cardWidth,
h: cardHeight,
title: normalizeTitle(child.display_name, child.agent_id || 'agent'),
subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`,
meta: [
`transport=${normalizeTitle(child.transport, 'node')} type=${normalizeTitle(child.type, 'worker')}`,
`source=${normalizeTitle(child.managed_by, 'remote_webui')}`,
t('remoteTasksUnavailable'),
],
accent: 'bg-violet-400',
clickable: true,
onClick: () => setSelectedTopologyBranch(branch),
});
lines.push({
x1: rootCard.x + cardWidth / 2,
y1: rootCard.y + cardHeight / 2,
x2: childX + cardWidth / 2,
y2: childY,
branch,
});
});
});
const highlightedBranch = selectedTopologyBranch.trim();
const branchFilters = new Map<string, boolean>();
branchFilters.set(localBranch, topologyFilter === 'all' || topologyFilter === 'local' || (topologyFilter === 'running' && localBranchStats.running > 0) || (topologyFilter === 'failed' && localBranchStats.failed > 0));
remoteTrees.forEach((tree, treeIndex) => {
const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`;
branchFilters.set(branch, topologyFilter === 'all' || topologyFilter === 'remote');
});
const decoratedCards = cards.map((card) => ({
...card,
hidden: branchFilters.get(card.branch) === false,
highlighted: !highlightedBranch || card.branch === highlightedBranch,
dimmed: branchFilters.get(card.branch) === false ? true : !!highlightedBranch && card.branch !== highlightedBranch,
}));
const decoratedLines = lines.map((line) => ({
...line,
hidden: branchFilters.get(line.branch) === false,
highlighted: !highlightedBranch || line.branch === highlightedBranch,
dimmed: branchFilters.get(line.branch) === false ? true : !!highlightedBranch && line.branch !== highlightedBranch,
}));
return { width, height, cards: decoratedCards, lines: decoratedLines };
}, [parsedNodeTrees, parsedNodes, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t]);
const callAction = async (payload: Record<string, any>) => {
const r = await fetch(`${apiPath}${q}`, {
@@ -322,52 +774,6 @@ const Subagents: React.FC = () => {
await load();
};
const draftConfigSubagent = async () => {
if (!draftDescription.trim()) {
await ui.notify({ title: t('requestFailed'), message: 'description is required' });
return;
}
const data = await callAction({
action: 'draft_config_subagent',
description: draftDescription,
agent_id_hint: configAgentID,
});
if (!data) return;
const draft = data?.result?.draft || {};
setConfigAgentID(draft.agent_id || '');
setConfigRole(draft.role || '');
setConfigDisplayName(draft.display_name || '');
setConfigSystemPrompt(draft.system_prompt || '');
setConfigSystemPromptFile(draft.system_prompt_file || '');
setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : '');
setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : '');
await load();
};
const fillDraftForm = (draft: PendingSubagentDraft['draft']) => {
if (!draft) return;
setConfigAgentID(draft.agent_id || '');
setConfigRole(draft.role || '');
setConfigDisplayName(draft.display_name || '');
setConfigSystemPrompt(draft.system_prompt || '');
setConfigSystemPromptFile(draft.system_prompt_file || '');
setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : '');
setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : '');
};
const clearPendingDraft = async (sessionKey: string) => {
const data = await callAction({ action: 'clear_pending_draft', session_key: sessionKey });
if (!data) return;
await load();
};
const confirmPendingDraft = async (sessionKey: string) => {
const data = await callAction({ action: 'confirm_pending_draft', session_key: sessionKey });
if (!data) return;
await ui.notify({ title: t('saved'), message: data?.result?.message || t('configSubagentSaved') });
await load();
};
const loadRegistryItem = (item: RegistrySubagent) => {
setConfigAgentID(item.agent_id || '');
setConfigRole(item.role || '');
@@ -443,6 +849,61 @@ const Subagents: React.FC = () => {
<button onClick={() => load()} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm">{t('refresh')}</button>
</div>
<div className="border border-zinc-800 rounded-2xl bg-zinc-900/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('agentTopology')}</div>
<div className="text-sm text-zinc-500">{t('agentTopologyHint')}</div>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
{(['all', 'running', 'failed', 'local', 'remote'] as const).map((filter) => (
<button
key={filter}
onClick={() => setTopologyFilter(filter)}
className={`px-2 py-1 rounded text-[11px] ${
topologyFilter === filter ? 'bg-amber-500/20 text-amber-200 border border-amber-500/40' : 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700'
}`}
>
{t(`topologyFilter.${filter}`)}
</button>
))}
{selectedTopologyBranch && (
<button
onClick={() => setSelectedTopologyBranch('')}
className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-200"
>
{t('clearFocus')}
</button>
)}
<div className="text-xs text-zinc-500">
{items.filter((item) => item.status === 'running').length} {t('runningTasks')}
</div>
</div>
</div>
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-zinc-800 bg-[radial-gradient(circle_at_top,_rgba(251,191,36,0.08),_transparent_24%),linear-gradient(180deg,rgba(24,24,27,0.95),rgba(9,9,11,0.98))]">
<svg width={topologyGraph.width} height={topologyGraph.height} className="block">
{topologyGraph.lines.map((line, idx) => (
line.hidden ? null : (
<line
key={`line-${idx}`}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
stroke={line.highlighted ? 'rgba(251,191,36,0.9)' : line.dashed ? 'rgba(251,191,36,0.5)' : 'rgba(113,113,122,0.7)'}
strokeWidth={line.highlighted ? '2.8' : '2'}
strokeDasharray={line.dashed ? '6 6' : undefined}
opacity={line.dimmed ? 0.22 : 1}
/>
)
))}
{topologyGraph.cards.map((card) => (
card.hidden ? null : <GraphCard key={card.key} card={card} />
))}
</svg>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-1 xl:grid-cols-[360px_1fr] gap-4">
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 overflow-hidden">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('subagentsRuntime')}</div>
@@ -526,17 +987,28 @@ const Subagents: React.FC = () => {
{registryItems.map((item) => (
<div key={item.agent_id || 'unknown'} className="px-3 py-2 border-b last:border-b-0 border-zinc-800/60 text-xs space-y-2">
<div className="text-zinc-100">{item.agent_id || '-'} · {item.role || '-'} · {item.enabled ? t('active') : t('paused')}</div>
<div className="text-zinc-400">{item.type || '-'} · {item.display_name || '-'}</div>
<div className="text-zinc-400">{item.type || '-'} · {item.transport || 'local'} · {item.display_name || '-'}</div>
{(item.node_id || item.parent_agent_id || item.managed_by) && (
<div className="text-zinc-500 break-words">
{item.node_id ? `node=${item.node_id}` : ''}
{item.node_id && item.parent_agent_id ? ' · ' : ''}
{item.parent_agent_id ? `parent=${item.parent_agent_id}` : ''}
{(item.node_id || item.parent_agent_id) && item.managed_by ? ' · ' : ''}
{item.managed_by ? `source=${item.managed_by}` : ''}
</div>
)}
<div className="text-zinc-500 break-words">{item.system_prompt_file || '-'}</div>
<div className="text-zinc-500">{item.prompt_file_found ? t('promptFileReady') : t('promptFileMissing')}</div>
<div className="text-zinc-300 whitespace-pre-wrap break-words">{item.system_prompt || item.description || '-'}</div>
<div className="text-zinc-500 break-words">{(item.routing_keywords || []).join(', ') || '-'}</div>
<div className="flex items-center gap-2">
<button onClick={() => loadRegistryItem(item)} className="px-2 py-1 rounded bg-indigo-700/70 hover:bg-indigo-600 text-[11px]">{t('loadDraft')}</button>
<button onClick={() => setRegistryEnabled(item, !item.enabled)} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-[11px]">
{item.enabled ? t('disableAgent') : t('enableAgent')}
</button>
{item.agent_id !== 'main' && (
{item.managed_by === 'config.json' && (
<button onClick={() => setRegistryEnabled(item, !item.enabled)} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-[11px]">
{item.enabled ? t('disableAgent') : t('enableAgent')}
</button>
)}
{item.managed_by === 'config.json' && item.agent_id !== 'main' && (
<button onClick={() => deleteRegistryItem(item)} className="px-2 py-1 rounded bg-red-700/70 hover:bg-red-600 text-[11px]">{t('delete')}</button>
)}
</div>
@@ -548,13 +1020,6 @@ const Subagents: React.FC = () => {
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('configSubagentDraft')}</div>
<textarea
value={draftDescription}
onChange={(e) => setDraftDescription(e.target.value)}
placeholder={t('subagentDraftDescription')}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded min-h-[90px]"
/>
<button onClick={draftConfigSubagent} className="px-3 py-1.5 text-xs rounded bg-zinc-700 hover:bg-zinc-600">{t('generateDraft')}</button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<input value={configAgentID} onChange={(e) => setConfigAgentID(e.target.value)} placeholder="agent_id" className="px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" />
<input value={configRole} onChange={(e) => setConfigRole(e.target.value)} placeholder="role" className="px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" />
@@ -595,28 +1060,6 @@ const Subagents: React.FC = () => {
</div>
</div>
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('pendingSubagentDrafts')}</div>
<button onClick={() => load()} className="px-2 py-1 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700">{t('refresh')}</button>
</div>
<div className="border border-zinc-800 rounded overflow-hidden">
{pendingDrafts.map((item) => (
<div key={item.session_key || 'unknown'} className="px-3 py-2 border-b last:border-b-0 border-zinc-800/60 text-xs space-y-2">
<div className="text-zinc-100">{item.draft?.agent_id || '-'} · {item.draft?.role || '-'}</div>
<div className="text-zinc-400">session: {item.session_key || '-'}</div>
<div className="text-zinc-300 whitespace-pre-wrap break-words">{item.draft?.system_prompt || item.draft?.description || '-'}</div>
<div className="flex items-center gap-2">
<button onClick={() => fillDraftForm(item.draft)} className="px-2 py-1 rounded bg-indigo-700/70 hover:bg-indigo-600 text-[11px]">{t('loadDraft')}</button>
<button onClick={() => confirmPendingDraft(item.session_key || 'main')} className="px-2 py-1 rounded bg-emerald-700/70 hover:bg-emerald-600 text-[11px]">{t('confirmDraft')}</button>
<button onClick={() => clearPendingDraft(item.session_key || 'main')} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-[11px]">{t('discardDraft')}</button>
</div>
</div>
))}
{pendingDrafts.length === 0 && <div className="px-3 py-4 text-sm text-zinc-500">{t('noPendingSubagentDrafts')}</div>}
</div>
</div>
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('dispatchAndWait')}</div>
<textarea

View File

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