mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-22 21:37:29 +08:00
1423 lines
55 KiB
TypeScript
1423 lines
55 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAppContext } from '../context/AppContext';
|
|
import { useUI } from '../context/UIContext';
|
|
import { Activity, Server, Cpu, Network, RefreshCw } from 'lucide-react';
|
|
import { SpaceParticles } from '../components/SpaceParticles';
|
|
import { FixedButton } from '../components/Button';
|
|
|
|
type SubagentTask = {
|
|
id: string;
|
|
status?: string;
|
|
label?: string;
|
|
role?: string;
|
|
agent_id?: string;
|
|
session_key?: string;
|
|
memory_ns?: string;
|
|
tool_allowlist?: string[];
|
|
max_retries?: number;
|
|
retry_count?: number;
|
|
retry_backoff?: number;
|
|
max_task_chars?: number;
|
|
max_result_chars?: number;
|
|
created?: number;
|
|
updated?: number;
|
|
task?: string;
|
|
result?: string;
|
|
thread_id?: string;
|
|
correlation_id?: string;
|
|
waiting_for_reply?: boolean;
|
|
};
|
|
|
|
type RouterReply = {
|
|
task_id?: string;
|
|
thread_id?: string;
|
|
correlation_id?: string;
|
|
agent_id?: string;
|
|
status?: string;
|
|
result?: string;
|
|
};
|
|
|
|
type AgentThread = {
|
|
thread_id?: string;
|
|
owner?: string;
|
|
participants?: string[];
|
|
status?: string;
|
|
topic?: string;
|
|
};
|
|
|
|
type AgentMessage = {
|
|
message_id?: string;
|
|
thread_id?: string;
|
|
from_agent?: string;
|
|
to_agent?: string;
|
|
reply_to?: string;
|
|
correlation_id?: string;
|
|
type?: string;
|
|
content?: string;
|
|
requires_reply?: boolean;
|
|
status?: string;
|
|
created_at?: number;
|
|
};
|
|
|
|
type StreamItem = {
|
|
kind?: 'event' | 'message' | string;
|
|
at?: number;
|
|
run_id?: string;
|
|
agent_id?: string;
|
|
event_type?: string;
|
|
message?: string;
|
|
retry_count?: number;
|
|
message_id?: string;
|
|
thread_id?: string;
|
|
from_agent?: string;
|
|
to_agent?: string;
|
|
reply_to?: string;
|
|
correlation_id?: string;
|
|
message_type?: string;
|
|
content?: string;
|
|
status?: string;
|
|
requires_reply?: boolean;
|
|
};
|
|
|
|
type RegistrySubagent = {
|
|
agent_id?: string;
|
|
enabled?: boolean;
|
|
type?: string;
|
|
transport?: string;
|
|
node_id?: string;
|
|
parent_agent_id?: string;
|
|
managed_by?: string;
|
|
notify_main_policy?: string;
|
|
display_name?: string;
|
|
role?: string;
|
|
description?: string;
|
|
system_prompt_file?: string;
|
|
prompt_file_found?: boolean;
|
|
memory_namespace?: string;
|
|
tool_allowlist?: string[];
|
|
inherited_tools?: string[];
|
|
effective_tools?: string[];
|
|
tool_visibility?: {
|
|
mode?: string;
|
|
inherited_tool_count?: number;
|
|
effective_tool_count?: number;
|
|
};
|
|
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;
|
|
latestStatus: string;
|
|
latestUpdated: 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[];
|
|
accentTone: 'success' | 'danger' | 'warning' | 'info' | 'accent' | 'neutral';
|
|
online?: boolean;
|
|
clickable?: boolean;
|
|
highlighted?: boolean;
|
|
dimmed?: boolean;
|
|
hidden?: boolean;
|
|
scale: number;
|
|
onClick?: () => void;
|
|
};
|
|
|
|
function graphAccentBackgroundClass(accentTone: GraphCardSpec['accentTone']) {
|
|
switch (accentTone) {
|
|
case 'success': return 'bg-gradient-to-br from-transparent to-emerald-500';
|
|
case 'danger': return 'bg-gradient-to-br from-transparent to-red-500';
|
|
case 'warning': return 'bg-gradient-to-br from-transparent to-amber-400';
|
|
case 'info': return 'bg-gradient-to-br from-transparent to-sky-400';
|
|
case 'accent': return 'bg-gradient-to-br from-transparent to-violet-400';
|
|
case 'neutral':
|
|
default: return 'bg-gradient-to-br from-transparent to-zinc-500';
|
|
}
|
|
}
|
|
|
|
function graphAccentIconClass(accentTone: GraphCardSpec['accentTone']) {
|
|
switch (accentTone) {
|
|
case 'success': return 'text-emerald-500';
|
|
case 'danger': return 'topology-icon-danger';
|
|
case 'warning': return 'text-amber-400';
|
|
case 'info': return 'text-sky-400';
|
|
case 'accent': return 'text-violet-400';
|
|
case 'neutral':
|
|
default: return 'text-zinc-500';
|
|
}
|
|
}
|
|
|
|
type GraphLineSpec = {
|
|
path: string;
|
|
dashed?: boolean;
|
|
branch: string;
|
|
highlighted?: boolean;
|
|
dimmed?: boolean;
|
|
hidden?: boolean;
|
|
};
|
|
|
|
type TopologyTooltipState = {
|
|
title: string;
|
|
subtitle: string;
|
|
meta: string[];
|
|
x: number;
|
|
y: number;
|
|
agentID?: string;
|
|
transportType?: 'local' | 'remote';
|
|
} | null;
|
|
|
|
type StreamPreviewState = {
|
|
task: SubagentTask | null;
|
|
items: StreamItem[];
|
|
taskID: string;
|
|
loading?: boolean;
|
|
};
|
|
|
|
type TopologyDragState = {
|
|
active: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
scrollLeft: number;
|
|
scrollTop: number;
|
|
};
|
|
|
|
const cardWidth = 140;
|
|
const cardHeight = 140;
|
|
const clusterGap = 60;
|
|
const sectionGap = 160;
|
|
const topY = 96;
|
|
const mainY = 96;
|
|
const childStartY = 320;
|
|
|
|
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 formatStreamTime(ts?: number): string {
|
|
if (!ts) return '--:--:--';
|
|
return new Date(ts).toLocaleTimeString([], { hour12: false });
|
|
}
|
|
|
|
function formatRuntimeTimestamp(value?: string): string {
|
|
const raw = `${value || ''}`.trim();
|
|
if (!raw || raw === '0001-01-01T00:00:00Z') return '-';
|
|
const ts = Date.parse(raw);
|
|
if (Number.isNaN(ts)) return raw;
|
|
return new Date(ts).toLocaleString();
|
|
}
|
|
|
|
function summarizePreviewText(value?: string, limit = 180): string {
|
|
const compact = `${value || ''}`.replace(/\s+/g, ' ').trim();
|
|
if (!compact) return '(empty)';
|
|
return compact.length > limit ? `${compact.slice(0, limit - 3)}...` : compact;
|
|
}
|
|
|
|
function tokenFromQuery(q: string): string {
|
|
const raw = String(q || '').trim();
|
|
if (!raw) return '';
|
|
const search = raw.startsWith('?') ? raw.slice(1) : raw;
|
|
const params = new URLSearchParams(search);
|
|
return params.get('token') || '';
|
|
}
|
|
|
|
function bezierCurve(x1: number, y1: number, x2: number, y2: number): string {
|
|
const offset = Math.max(Math.abs(y2 - y1) * 0.5, 60);
|
|
return `M ${x1} ${y1} C ${x1} ${y1 + offset} ${x2} ${y2 - offset} ${x2} ${y2}`;
|
|
}
|
|
|
|
function horizontalBezierCurve(x1: number, y1: number, x2: number, y2: number): string {
|
|
const offset = Math.max(Math.abs(x2 - x1) * 0.5, 60);
|
|
return `M ${x1} ${y1} C ${x1 + offset} ${y1} ${x2 - offset} ${y2} ${x2} ${y2}`;
|
|
}
|
|
|
|
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, latestStatus: '', latestUpdated: 0, active: [] };
|
|
}
|
|
const item = acc[agentID];
|
|
item.total += 1;
|
|
if (task.status === 'running') item.running += 1;
|
|
if (task.waiting_for_reply) item.waiting += 1;
|
|
const updatedAt = Math.max(task.updated || 0, task.created || 0);
|
|
if (updatedAt >= item.latestUpdated) {
|
|
item.latestUpdated = updatedAt;
|
|
item.latestStatus = normalizeTitle(task.status, '');
|
|
item.failed = task.status === 'failed' ? 1 : 0;
|
|
}
|
|
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,
|
|
onHover,
|
|
onLeave,
|
|
onDragStart,
|
|
}: {
|
|
card: GraphCardSpec;
|
|
onHover: (card: GraphCardSpec, event: React.MouseEvent<HTMLDivElement>) => void;
|
|
onLeave: () => void;
|
|
onDragStart: (key: string, event: React.MouseEvent<HTMLDivElement>) => void;
|
|
}) {
|
|
const isNode = card.kind === 'node';
|
|
const Icon = isNode ? Server : Cpu;
|
|
|
|
return (
|
|
<foreignObject
|
|
x={card.x}
|
|
y={card.y}
|
|
width={card.w}
|
|
height={card.h}
|
|
className="overflow-visible"
|
|
>
|
|
<div
|
|
onMouseDown={(e) => onDragStart(card.key, e)}
|
|
onClick={(e) => {
|
|
if (e.defaultPrevented) return;
|
|
card.onClick?.();
|
|
}}
|
|
onMouseEnter={(event) => onHover(card, event)}
|
|
onMouseMove={(event) => onHover(card, event)}
|
|
onMouseLeave={onLeave}
|
|
className={`relative w-full h-full rounded-full flex flex-col items-center justify-center gap-1 transition-all duration-300 group ${card.highlighted
|
|
? 'scale-[1.05] z-10'
|
|
: 'hover:scale-[1.02]'
|
|
}`}
|
|
style={{
|
|
cursor: card.clickable ? 'pointer' : 'default',
|
|
opacity: card.dimmed ? 0.3 : 1,
|
|
}}
|
|
>
|
|
{/* Sleek Glass Node Background */}
|
|
<div className={`absolute inset-0 rounded-full transition-all duration-300 backdrop-blur-md ${card.highlighted
|
|
? 'topology-node-highlight'
|
|
: 'topology-node-base'
|
|
}`}>
|
|
{/* Base dark glass */}
|
|
<div className="absolute inset-0 rounded-full bg-gradient-to-b from-zinc-800/95 to-zinc-950/95" />
|
|
|
|
{/* Subtle accent glow */}
|
|
<div className={`absolute inset-0 rounded-full opacity-20 ${graphAccentBackgroundClass(card.accentTone)}`} />
|
|
|
|
{/* Inner depth ring */}
|
|
<div className="topology-node-inner-border absolute inset-[1px] rounded-full border" />
|
|
|
|
{/* Border ring */}
|
|
<div className={`absolute inset-0 rounded-full border-[1.5px] ${card.highlighted ? 'topology-node-border-highlight' : 'border-zinc-700/80 group-hover:border-zinc-500/80'
|
|
}`} />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="relative z-10 flex flex-col items-center justify-center w-full px-4 text-center">
|
|
<div className={`flex items-center justify-center w-10 h-10 mb-1 rounded-full bg-zinc-950/60 border border-zinc-700/50 shadow-inner backdrop-blur-sm`}>
|
|
<Icon className={`w-5 h-5 ${graphAccentIconClass(card.accentTone)}`} />
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<div className="text-[13px] font-bold text-zinc-100 truncate leading-tight drop-shadow-md">{card.title}</div>
|
|
<div className="text-[10px] text-zinc-300/90 truncate mt-0.5 drop-shadow-sm">{card.subtitle}</div>
|
|
</div>
|
|
|
|
{card.online !== undefined && (
|
|
<div className={`absolute top-6 right-6 w-2.5 h-2.5 rounded-full border border-zinc-900 ${card.online ? 'status-dot-online topology-online-indicator' : 'status-dot-offline'}`} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</foreignObject>
|
|
);
|
|
}
|
|
|
|
const Subagents: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { q, nodeTrees, nodeP2P, nodeDispatchItems, subagentRuntimeItems, subagentRegistryItems } = useAppContext();
|
|
const ui = useUI();
|
|
|
|
const [items, setItems] = useState<SubagentTask[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string>('');
|
|
const [selectedAgentID, setSelectedAgentID] = useState<string>('');
|
|
const [spawnTask, setSpawnTask] = useState('');
|
|
const [spawnAgentID, setSpawnAgentID] = useState('');
|
|
const [spawnRole, setSpawnRole] = useState('');
|
|
const [spawnLabel, setSpawnLabel] = useState('');
|
|
const [steerMessage, setSteerMessage] = useState('');
|
|
const [dispatchTask, setDispatchTask] = useState('');
|
|
const [dispatchAgentID, setDispatchAgentID] = useState('');
|
|
const [dispatchRole, setDispatchRole] = useState('');
|
|
const [dispatchReply, setDispatchReply] = useState<RouterReply | null>(null);
|
|
const [dispatchMerged, setDispatchMerged] = useState('');
|
|
const [threadDetail, setThreadDetail] = useState<AgentThread | null>(null);
|
|
const [threadMessages, setThreadMessages] = useState<AgentMessage[]>([]);
|
|
const [inboxMessages, setInboxMessages] = useState<AgentMessage[]>([]);
|
|
const [replyMessage, setReplyMessage] = useState('');
|
|
const [replyToMessageID, setReplyToMessageID] = useState('');
|
|
const [configAgentID, setConfigAgentID] = useState('');
|
|
const [configRole, setConfigRole] = useState('');
|
|
const [configDisplayName, setConfigDisplayName] = useState('');
|
|
const [configSystemPromptFile, setConfigSystemPromptFile] = useState('');
|
|
const [configToolAllowlist, setConfigToolAllowlist] = useState('');
|
|
const [configRoutingKeywords, setConfigRoutingKeywords] = useState('');
|
|
const [registryItems, setRegistryItems] = useState<RegistrySubagent[]>([]);
|
|
const [promptFileContent, setPromptFileContent] = useState('');
|
|
const [promptFileFound, setPromptFileFound] = useState(false);
|
|
const [streamPreviewByAgent, setStreamPreviewByAgent] = useState<Record<string, StreamPreviewState>>({});
|
|
const [selectedTopologyBranch, setSelectedTopologyBranch] = useState('');
|
|
const [topologyFilter, setTopologyFilter] = useState<'all' | 'running' | 'failed' | 'local' | 'remote'>('all');
|
|
const [topologyZoom, setTopologyZoom] = useState(0.9);
|
|
const [topologyPan, setTopologyPan] = useState({ x: 0, y: 0 });
|
|
const [nodeOverrides, setNodeOverrides] = useState<Record<string, { x: number, y: number }>>({});
|
|
const [draggedNode, setDraggedNode] = useState<string | null>(null);
|
|
const [topologyTooltip, setTopologyTooltip] = useState<TopologyTooltipState>(null);
|
|
const [topologyDragging, setTopologyDragging] = useState(false);
|
|
const topologyViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const topologyDragRef = useRef<{
|
|
active: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
panX: number;
|
|
panY: number;
|
|
}>({
|
|
active: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
panX: 0,
|
|
panY: 0,
|
|
});
|
|
const nodeDragRef = useRef<{
|
|
startX: number;
|
|
startY: number;
|
|
initialNodeX: number;
|
|
initialNodeY: number;
|
|
}>({ startX: 0, startY: 0, initialNodeX: 0, initialNodeY: 0 });
|
|
const hasFittedRef = useRef(false);
|
|
const streamPreviewLoadingRef = useRef<Record<string, string>>({});
|
|
|
|
const apiPath = '/webui/api/subagents_runtime';
|
|
const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`;
|
|
|
|
const openAgentStream = (agentID: string, taskID = '', branch = '') => {
|
|
if (branch) setSelectedTopologyBranch(branch);
|
|
setSelectedAgentID(agentID);
|
|
setSelectedId(taskID);
|
|
};
|
|
|
|
const load = async () => {
|
|
try {
|
|
if (subagentRuntimeItems.length > 0 || subagentRegistryItems.length > 0) {
|
|
const arr = Array.isArray(subagentRuntimeItems) ? subagentRuntimeItems : [];
|
|
const registry = Array.isArray(subagentRegistryItems) ? subagentRegistryItems : [];
|
|
setItems(arr);
|
|
setRegistryItems(registry);
|
|
if (registry.length === 0) {
|
|
setSelectedAgentID('');
|
|
setSelectedId('');
|
|
} else {
|
|
const nextAgentID = selectedAgentID && registry.find((x: RegistrySubagent) => x.agent_id === selectedAgentID)
|
|
? selectedAgentID
|
|
: (registry[0]?.agent_id || '');
|
|
setSelectedAgentID(nextAgentID);
|
|
const nextTask = arr.find((x: SubagentTask) => x.agent_id === nextAgentID);
|
|
setSelectedId(nextTask?.id || '');
|
|
}
|
|
return;
|
|
}
|
|
const [tasksRes, registryRes] = await Promise.all([
|
|
fetch(withAction('list')),
|
|
fetch(withAction('registry')),
|
|
]);
|
|
if (!tasksRes.ok) throw new Error(await tasksRes.text());
|
|
if (!registryRes.ok) throw new Error(await registryRes.text());
|
|
const j = await tasksRes.json();
|
|
const registryJson = await registryRes.json();
|
|
const arr = Array.isArray(j?.result?.items) ? j.result.items : [];
|
|
const registryItems = Array.isArray(registryJson?.result?.items) ? registryJson.result.items : [];
|
|
setItems(arr);
|
|
setRegistryItems(registryItems);
|
|
if (registryItems.length === 0) {
|
|
setSelectedAgentID('');
|
|
setSelectedId('');
|
|
} else {
|
|
const nextAgentID = selectedAgentID && registryItems.find((x: RegistrySubagent) => x.agent_id === selectedAgentID)
|
|
? selectedAgentID
|
|
: (registryItems[0]?.agent_id || '');
|
|
setSelectedAgentID(nextAgentID);
|
|
const nextTask = arr.find((x: SubagentTask) => x.agent_id === nextAgentID);
|
|
setSelectedId(nextTask?.id || '');
|
|
}
|
|
} catch (e) {
|
|
// Mock data for preview
|
|
setItems([
|
|
{ id: 'task-1', status: 'running', agent_id: 'worker-1', role: 'worker', task: 'Process data stream', created: Date.now() }
|
|
]);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load().catch(() => { });
|
|
}, [q, selectedAgentID, subagentRuntimeItems, subagentRegistryItems]);
|
|
|
|
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 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 p2pSessionByNode = useMemo(() => {
|
|
const out: Record<string, any> = {};
|
|
const sessions = Array.isArray(nodeP2P?.nodes) ? nodeP2P.nodes : [];
|
|
sessions.forEach((session: any) => {
|
|
const nodeID = normalizeTitle(session?.node, '');
|
|
if (!nodeID) return;
|
|
out[nodeID] = session;
|
|
});
|
|
return out;
|
|
}, [nodeP2P]);
|
|
const recentDispatchByNode = useMemo(() => {
|
|
const out: Record<string, any> = {};
|
|
const rows = Array.isArray(nodeDispatchItems) ? nodeDispatchItems : [];
|
|
rows.forEach((row: any) => {
|
|
const nodeID = normalizeTitle(row?.node, '');
|
|
if (!nodeID || out[nodeID]) return;
|
|
out[nodeID] = row;
|
|
});
|
|
return out;
|
|
}, [nodeDispatchItems]);
|
|
const topologyGraph = useMemo(() => {
|
|
const scale = topologyZoom;
|
|
const originX = 56;
|
|
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 localChildren = Array.isArray(localRoot.children) ? localRoot.children : [];
|
|
const localBranchWidth = Math.max(cardWidth, localChildren.length * (cardWidth + clusterGap) - clusterGap);
|
|
const localOriginX = originX;
|
|
const localMainX = localOriginX + Math.max(0, (localBranchWidth - cardWidth) / 2);
|
|
const localMainCenterX = localMainX + cardWidth / 2;
|
|
const localMainCenterY = mainY + cardHeight / 2;
|
|
const remoteClusters = remoteTrees.map((tree) => {
|
|
const root = tree.root?.root;
|
|
const children = Array.isArray(root?.children) ? root.children : [];
|
|
return {
|
|
tree,
|
|
root,
|
|
children,
|
|
width: Math.max(cardWidth, children.length * (cardWidth + clusterGap) - clusterGap),
|
|
};
|
|
});
|
|
const totalRemoteWidth = remoteClusters.reduce((sum, cluster, idx) => {
|
|
return sum + cluster.width + (idx > 0 ? sectionGap : 0);
|
|
}, 0);
|
|
const width = Math.max(900, localOriginX * 2 + localBranchWidth + (remoteClusters.length > 0 ? sectionGap + totalRemoteWidth : 0));
|
|
const height = childStartY + cardHeight + 40;
|
|
const cards: GraphCardSpec[] = [];
|
|
const lines: GraphLineSpec[] = [];
|
|
const localBranch = 'local';
|
|
const localBranchStats = {
|
|
running: 0,
|
|
failed: 0,
|
|
};
|
|
|
|
const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] };
|
|
const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')];
|
|
const localMainRegistry = registryItems.find((item) => item.agent_id === localRoot.agent_id);
|
|
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: [
|
|
`children=${localChildren.length + remoteClusters.length}`,
|
|
`total=${localMainStats.total} running=${localMainStats.running}`,
|
|
`waiting=${localMainStats.waiting} failed=${localMainStats.failed}`,
|
|
`notify=${normalizeTitle(localMainRegistry?.notify_main_policy, 'final_only')}`,
|
|
`transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`,
|
|
`tools=${normalizeTitle(localMainRegistry?.tool_visibility?.mode, 'allowlist')} visible=${localMainRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${localMainRegistry?.tool_visibility?.inherited_tool_count ?? 0}`,
|
|
(localMainRegistry?.inherited_tools || []).length ? `inherits: ${(localMainRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -',
|
|
localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'),
|
|
],
|
|
accentTone: localMainStats.running > 0 ? 'success' : localMainStats.latestStatus === 'failed' ? 'danger' : 'warning',
|
|
clickable: true,
|
|
scale,
|
|
onClick: () => {
|
|
openAgentStream(normalizeTitle(localRoot.agent_id, 'main'), localMainTask?.id || '', localBranch);
|
|
},
|
|
};
|
|
cards.push(localMainCard);
|
|
|
|
localChildren.forEach((child, idx) => {
|
|
const childX = localOriginX + idx * (cardWidth + clusterGap);
|
|
const childY = childStartY;
|
|
const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] };
|
|
const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')];
|
|
const childRegistry = registryItems.find((item) => item.agent_id === 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}`,
|
|
`notify=${normalizeTitle(childRegistry?.notify_main_policy, 'final_only')}`,
|
|
`transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`,
|
|
`tools=${normalizeTitle(childRegistry?.tool_visibility?.mode, 'allowlist')} visible=${childRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${childRegistry?.tool_visibility?.inherited_tool_count ?? 0}`,
|
|
(childRegistry?.inherited_tools || []).length ? `inherits: ${(childRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -',
|
|
stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'),
|
|
],
|
|
accentTone: stats.running > 0 ? 'success' : stats.latestStatus === 'failed' ? 'danger' : 'info',
|
|
clickable: true,
|
|
scale,
|
|
onClick: () => {
|
|
openAgentStream(normalizeTitle(child.agent_id, ''), task?.id || '', localBranch);
|
|
},
|
|
});
|
|
lines.push({
|
|
path: bezierCurve(localMainCard.x + cardWidth / 2, localMainCard.y + cardHeight / 2, childX + cardWidth / 2, childY + cardHeight / 2),
|
|
branch: localBranch,
|
|
});
|
|
});
|
|
|
|
let remoteOffsetX = localOriginX + localBranchWidth + sectionGap;
|
|
remoteClusters.forEach((cluster, treeIndex) => {
|
|
const { tree, root: treeRoot, children } = cluster;
|
|
const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`;
|
|
const nodeID = normalizeTitle(tree.node_id, '');
|
|
const p2pSession = p2pSessionByNode[nodeID];
|
|
const recentDispatch = recentDispatchByNode[nodeID];
|
|
const rootX = remoteOffsetX + Math.max(0, (cluster.width - cardWidth) / 2);
|
|
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: [
|
|
`status=${tree.online ? t('online') : t('offline')}`,
|
|
`transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`,
|
|
`p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`,
|
|
`last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`,
|
|
`last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`,
|
|
`retry=${Number(p2pSession?.retry_count || 0)}`,
|
|
`${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`,
|
|
`source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`,
|
|
t('remoteTasksUnavailable'),
|
|
],
|
|
accentTone: !tree.online ? 'neutral' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'success' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'warning' : 'accent',
|
|
clickable: true,
|
|
scale,
|
|
onClick: () => {
|
|
openAgentStream(normalizeTitle(treeRoot.agent_id, ''), '', branch);
|
|
},
|
|
};
|
|
cards.push(rootCard);
|
|
lines.push({
|
|
path: horizontalBezierCurve(localMainCard.x + cardWidth / 2, localMainCard.y + cardHeight / 2, rootCard.x + cardWidth / 2, rootCard.y + cardHeight / 2),
|
|
dashed: true,
|
|
branch,
|
|
});
|
|
children.forEach((child, idx) => {
|
|
const childX = remoteOffsetX + idx * (cardWidth + clusterGap);
|
|
const childY = childStartY;
|
|
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')}`,
|
|
`p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`,
|
|
`last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`,
|
|
`last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`,
|
|
`retry=${Number(p2pSession?.retry_count || 0)}`,
|
|
`${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`,
|
|
`source=${normalizeTitle(child.managed_by, 'remote_webui')}`,
|
|
t('remoteTasksUnavailable'),
|
|
],
|
|
accentTone: normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'success' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'warning' : 'accent',
|
|
clickable: true,
|
|
scale,
|
|
onClick: () => {
|
|
openAgentStream(normalizeTitle(child.agent_id, ''), '', branch);
|
|
},
|
|
});
|
|
lines.push({
|
|
path: bezierCurve(rootCard.x + cardWidth / 2, rootCard.y + cardHeight / 2, childX + cardWidth / 2, childY + cardHeight / 2),
|
|
branch,
|
|
});
|
|
});
|
|
remoteOffsetX += cluster.width + sectionGap;
|
|
});
|
|
|
|
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) => {
|
|
const override = nodeOverrides[card.key];
|
|
return {
|
|
...card,
|
|
x: override ? override.x : card.x,
|
|
y: override ? override.y : card.y,
|
|
hidden: branchFilters.get(card.branch) === false,
|
|
highlighted: !highlightedBranch || card.branch === highlightedBranch,
|
|
dimmed: branchFilters.get(card.branch) === false ? true : !!highlightedBranch && card.branch !== highlightedBranch,
|
|
};
|
|
});
|
|
|
|
// Recalculate lines based on potentially overridden card positions
|
|
const recalculatedLines: GraphLineSpec[] = [];
|
|
|
|
// Helper to find a card's current position
|
|
const getCardPos = (key: string) => {
|
|
const c = decoratedCards.find(c => c.key === key);
|
|
return c ? { cx: c.x + cardWidth / 2, cy: c.y + cardHeight / 2 } : null;
|
|
};
|
|
|
|
const localMainPos = getCardPos('agent-main');
|
|
|
|
localChildren.forEach((child, idx) => {
|
|
const childKey = `local-child-${child.agent_id || idx}`;
|
|
const childPos = getCardPos(childKey);
|
|
if (localMainPos && childPos) {
|
|
recalculatedLines.push({
|
|
path: bezierCurve(localMainPos.cx, localMainPos.cy, childPos.cx, childPos.cy),
|
|
branch: localBranch,
|
|
});
|
|
}
|
|
});
|
|
|
|
remoteClusters.forEach((cluster, treeIndex) => {
|
|
const branch = `node:${normalizeTitle(cluster.tree.node_id, `remote-${treeIndex}`)}`;
|
|
const remoteRootKey = `remote-root-${cluster.tree.node_id || treeIndex}`;
|
|
const remoteRootPos = getCardPos(remoteRootKey);
|
|
|
|
if (localMainPos && remoteRootPos) {
|
|
recalculatedLines.push({
|
|
path: horizontalBezierCurve(localMainPos.cx, localMainPos.cy, remoteRootPos.cx, remoteRootPos.cy),
|
|
dashed: true,
|
|
branch,
|
|
});
|
|
}
|
|
|
|
cluster.children.forEach((child, idx) => {
|
|
const childKey = `remote-child-${cluster.tree.node_id || treeIndex}-${child.agent_id || idx}`;
|
|
const childPos = getCardPos(childKey);
|
|
if (remoteRootPos && childPos) {
|
|
recalculatedLines.push({
|
|
path: bezierCurve(remoteRootPos.cx, remoteRootPos.cy, childPos.cx, childPos.cy),
|
|
branch,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
const decoratedLines = recalculatedLines.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, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t, topologyZoom, nodeOverrides, nodeP2P, p2pSessionByNode, recentDispatchByNode]);
|
|
|
|
const fitView = () => {
|
|
const viewport = topologyViewportRef.current;
|
|
if (!viewport || !topologyGraph.width) return;
|
|
const availableW = viewport.clientWidth;
|
|
const availableH = viewport.clientHeight;
|
|
|
|
const fitted = Math.min(1.15, Math.max(0.2, (availableW - 48) / topologyGraph.width));
|
|
|
|
setTopologyZoom(fitted);
|
|
setTopologyPan({
|
|
x: (availableW - topologyGraph.width * fitted) / 2,
|
|
y: Math.max(24, (availableH - topologyGraph.height * fitted) / 2)
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!hasFittedRef.current && topologyGraph.width > 0) {
|
|
fitView();
|
|
hasFittedRef.current = true;
|
|
}
|
|
}, [topologyGraph.width]);
|
|
|
|
useEffect(() => {
|
|
const viewport = topologyViewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
if (!(e.ctrlKey || e.metaKey)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
|
|
const rect = viewport.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
setTopologyZoom(prevZoom => {
|
|
const zoomSensitivity = 0.002;
|
|
const delta = -e.deltaY * zoomSensitivity;
|
|
const newZoom = Math.min(Math.max(0.1, prevZoom * (1 + delta)), 4);
|
|
|
|
setTopologyPan(prevPan => {
|
|
const scaleRatio = newZoom / prevZoom;
|
|
const newPanX = mouseX - (mouseX - prevPan.x) * scaleRatio;
|
|
const newPanY = mouseY - (mouseY - prevPan.y) * scaleRatio;
|
|
return { x: newPanX, y: newPanY };
|
|
});
|
|
|
|
return newZoom;
|
|
});
|
|
};
|
|
|
|
viewport.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => viewport.removeEventListener('wheel', handleWheel);
|
|
}, []);
|
|
|
|
const handleTopologyHover = (card: GraphCardSpec, event: React.MouseEvent<HTMLDivElement>) => {
|
|
const tooltipWidth = 360;
|
|
const tooltipHeight = 420;
|
|
let x = event.clientX + 14;
|
|
let y = event.clientY + 14;
|
|
|
|
if (x + tooltipWidth > window.innerWidth) {
|
|
x = event.clientX - tooltipWidth - 14;
|
|
}
|
|
if (y + tooltipHeight > window.innerHeight) {
|
|
y = event.clientY - tooltipHeight - 14;
|
|
}
|
|
|
|
setTopologyTooltip({
|
|
title: card.title,
|
|
subtitle: card.subtitle,
|
|
meta: card.meta,
|
|
x,
|
|
y,
|
|
agentID: card.agentID,
|
|
transportType: card.transportType,
|
|
});
|
|
};
|
|
|
|
const clearTopologyTooltip = () => setTopologyTooltip(null);
|
|
|
|
const handleNodeDragStart = (key: string, event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0) return;
|
|
event.stopPropagation();
|
|
|
|
const card = topologyGraph.cards.find(c => c.key === key);
|
|
if (!card) return;
|
|
|
|
setDraggedNode(key);
|
|
nodeDragRef.current = {
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
initialNodeX: card.x,
|
|
initialNodeY: card.y,
|
|
};
|
|
clearTopologyTooltip();
|
|
};
|
|
|
|
const startTopologyDrag = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0) return;
|
|
topologyDragRef.current = {
|
|
active: true,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
panX: topologyPan.x,
|
|
panY: topologyPan.y,
|
|
};
|
|
setTopologyDragging(true);
|
|
clearTopologyTooltip();
|
|
};
|
|
|
|
const moveTopologyDrag = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (draggedNode) {
|
|
const deltaX = (event.clientX - nodeDragRef.current.startX) / topologyZoom;
|
|
const deltaY = (event.clientY - nodeDragRef.current.startY) / topologyZoom;
|
|
|
|
setNodeOverrides(prev => ({
|
|
...prev,
|
|
[draggedNode]: {
|
|
x: nodeDragRef.current.initialNodeX + deltaX,
|
|
y: nodeDragRef.current.initialNodeY + deltaY,
|
|
}
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!topologyDragRef.current.active) return;
|
|
const deltaX = event.clientX - topologyDragRef.current.startX;
|
|
const deltaY = event.clientY - topologyDragRef.current.startY;
|
|
setTopologyPan({
|
|
x: topologyDragRef.current.panX + deltaX,
|
|
y: topologyDragRef.current.panY + deltaY,
|
|
});
|
|
};
|
|
|
|
const stopTopologyDrag = () => {
|
|
if (draggedNode) {
|
|
setDraggedNode(null);
|
|
}
|
|
if (topologyDragRef.current.active) {
|
|
topologyDragRef.current.active = false;
|
|
setTopologyDragging(false);
|
|
}
|
|
};
|
|
|
|
const callAction = async (payload: Record<string, any>) => {
|
|
const r = await fetch(`${apiPath}${q}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!r.ok) {
|
|
await ui.notify({ title: t('requestFailed'), message: await r.text() });
|
|
return null;
|
|
}
|
|
return r.json();
|
|
};
|
|
|
|
const loadThreadAndInbox = async (task: SubagentTask | null) => {
|
|
if (!task?.id) {
|
|
setThreadDetail(null);
|
|
setThreadMessages([]);
|
|
setInboxMessages([]);
|
|
return;
|
|
}
|
|
try {
|
|
const [threadRes, inboxRes] = await Promise.all([
|
|
callAction({ action: 'thread', id: task.id, limit: 50 }),
|
|
callAction({ action: 'inbox', id: task.id, limit: 50 }),
|
|
]);
|
|
setThreadDetail(threadRes?.result?.thread || null);
|
|
setThreadMessages(Array.isArray(threadRes?.result?.messages) ? threadRes.result.messages : []);
|
|
setInboxMessages(Array.isArray(inboxRes?.result?.messages) ? inboxRes.result.messages : []);
|
|
} catch (e) { }
|
|
};
|
|
|
|
const loadStreamPreview = async (agentID: string, task: SubagentTask | null) => {
|
|
const taskID = task?.id || '';
|
|
if (!agentID) return;
|
|
if (streamPreviewLoadingRef.current[agentID] === taskID) return;
|
|
const existing = streamPreviewByAgent[agentID];
|
|
if (existing && existing.taskID === taskID && !existing.loading) return;
|
|
|
|
streamPreviewLoadingRef.current[agentID] = taskID;
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[agentID]: {
|
|
task: task || null,
|
|
items: prev[agentID]?.items || [],
|
|
taskID,
|
|
loading: !!taskID,
|
|
},
|
|
}));
|
|
|
|
if (!taskID) {
|
|
delete streamPreviewLoadingRef.current[agentID];
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[agentID]: { task: null, items: [], taskID: '', loading: false },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const streamRes = await callAction({ action: 'stream', id: taskID, limit: 12 });
|
|
delete streamPreviewLoadingRef.current[agentID];
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[agentID]: {
|
|
task: streamRes?.result?.task || task,
|
|
items: Array.isArray(streamRes?.result?.items) ? streamRes.result.items : [],
|
|
taskID,
|
|
loading: false,
|
|
},
|
|
}));
|
|
} catch {
|
|
delete streamPreviewLoadingRef.current[agentID];
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[agentID]: { task: task || null, items: [], taskID, loading: false },
|
|
}));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const selectedTaskID = String(selected?.id || '').trim();
|
|
const previewAgentID = topologyTooltip?.transportType === 'local' ? String(topologyTooltip.agentID || '').trim() : '';
|
|
const previewTask = previewAgentID ? recentTaskByAgent[previewAgentID] || null : null;
|
|
const previewTaskID = String(previewTask?.id || '').trim();
|
|
|
|
if (!selectedTaskID) {
|
|
setThreadDetail(null);
|
|
setThreadMessages([]);
|
|
setInboxMessages([]);
|
|
}
|
|
if (!previewAgentID) {
|
|
return;
|
|
}
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[previewAgentID]: {
|
|
task: previewTask,
|
|
items: prev[previewAgentID]?.items || [],
|
|
taskID: previewTaskID,
|
|
loading: !!previewTaskID,
|
|
},
|
|
}));
|
|
|
|
if (!selectedTaskID && !previewTaskID) return;
|
|
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const url = new URL(`${proto}//${window.location.host}/webui/api/subagents_runtime/live`);
|
|
if (tokenFromQuery(q)) url.searchParams.set('token', tokenFromQuery(q));
|
|
if (selectedTaskID) url.searchParams.set('task_id', selectedTaskID);
|
|
if (previewTaskID) url.searchParams.set('preview_task_id', previewTaskID);
|
|
|
|
const ws = new WebSocket(url.toString());
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
const payload = msg?.payload || {};
|
|
if (payload.thread) {
|
|
setThreadDetail(payload.thread.thread || null);
|
|
setThreadMessages(Array.isArray(payload.thread.messages) ? payload.thread.messages : []);
|
|
}
|
|
if (payload.inbox) {
|
|
setInboxMessages(Array.isArray(payload.inbox.messages) ? payload.inbox.messages : []);
|
|
}
|
|
if (previewAgentID && payload.preview) {
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[previewAgentID]: {
|
|
task: payload.preview.task || previewTask,
|
|
items: Array.isArray(payload.preview.items) ? payload.preview.items : [],
|
|
taskID: previewTaskID,
|
|
loading: false,
|
|
},
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
if (previewAgentID) {
|
|
setStreamPreviewByAgent((prev) => ({
|
|
...prev,
|
|
[previewAgentID]: {
|
|
task: previewTask,
|
|
items: prev[previewAgentID]?.items || [],
|
|
taskID: previewTaskID,
|
|
loading: false,
|
|
},
|
|
}));
|
|
}
|
|
};
|
|
return () => {
|
|
ws.close();
|
|
};
|
|
}, [selected?.id, topologyTooltip?.agentID, topologyTooltip?.transportType, recentTaskByAgent, q]);
|
|
|
|
return (
|
|
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<h1 className="text-xl md:text-2xl font-semibold">{t('subagentsRuntime')}</h1>
|
|
<FixedButton onClick={() => load()} variant="primary" label={t('refresh')}>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</FixedButton>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 brand-card border border-zinc-800 p-4 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<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-xl text-[11px] ${topologyFilter === filter ? 'control-chip-active' : 'control-chip'
|
|
}`}
|
|
>
|
|
{t(`topologyFilter.${filter}`)}
|
|
</button>
|
|
))}
|
|
{selectedTopologyBranch && (
|
|
<button
|
|
onClick={() => setSelectedTopologyBranch('')}
|
|
className="px-2 py-1 rounded-xl text-[11px] control-chip"
|
|
>
|
|
{t('clearFocus')}
|
|
</button>
|
|
)}
|
|
<div className="control-chip-group flex items-center gap-1 rounded-xl px-1 py-1">
|
|
<button
|
|
onClick={() => {
|
|
const newZoom = Math.max(0.1, Number((topologyZoom - 0.1).toFixed(2)));
|
|
const viewport = topologyViewportRef.current;
|
|
if (viewport) {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const mouseX = rect.width / 2;
|
|
const mouseY = rect.height / 2;
|
|
const scaleRatio = newZoom / topologyZoom;
|
|
setTopologyPan(prev => ({
|
|
x: mouseX - (mouseX - prev.x) * scaleRatio,
|
|
y: mouseY - (mouseY - prev.y) * scaleRatio
|
|
}));
|
|
}
|
|
setTopologyZoom(newZoom);
|
|
}}
|
|
className="px-2 py-1 rounded-lg text-[11px] control-chip"
|
|
>
|
|
{t('zoomOut')}
|
|
</button>
|
|
<button
|
|
onClick={fitView}
|
|
className="px-2 py-1 rounded-lg text-[11px] control-chip"
|
|
>
|
|
{t('fitView')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const newZoom = 1;
|
|
const viewport = topologyViewportRef.current;
|
|
if (viewport) {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const mouseX = rect.width / 2;
|
|
const mouseY = rect.height / 2;
|
|
const scaleRatio = newZoom / topologyZoom;
|
|
setTopologyPan(prev => ({
|
|
x: mouseX - (mouseX - prev.x) * scaleRatio,
|
|
y: mouseY - (mouseY - prev.y) * scaleRatio
|
|
}));
|
|
}
|
|
setTopologyZoom(newZoom);
|
|
}}
|
|
className="px-2 py-1 rounded-lg text-[11px] control-chip"
|
|
>
|
|
100%
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const newZoom = Math.min(4, Number((topologyZoom + 0.1).toFixed(2)));
|
|
const viewport = topologyViewportRef.current;
|
|
if (viewport) {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const mouseX = rect.width / 2;
|
|
const mouseY = rect.height / 2;
|
|
const scaleRatio = newZoom / topologyZoom;
|
|
setTopologyPan(prev => ({
|
|
x: mouseX - (mouseX - prev.x) * scaleRatio,
|
|
y: mouseY - (mouseY - prev.y) * scaleRatio
|
|
}));
|
|
}
|
|
setTopologyZoom(newZoom);
|
|
}}
|
|
className="px-2 py-1 rounded-lg text-[11px] control-chip"
|
|
>
|
|
{t('zoomIn')}
|
|
</button>
|
|
</div>
|
|
<div className="text-xs text-zinc-400">
|
|
{Math.round(topologyZoom * 100)}% · {items.filter((item) => item.status === 'running').length} {t('runningTasks')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={topologyViewportRef}
|
|
onMouseDown={startTopologyDrag}
|
|
onMouseMove={moveTopologyDrag}
|
|
onMouseUp={stopTopologyDrag}
|
|
onMouseLeave={() => {
|
|
stopTopologyDrag();
|
|
clearTopologyTooltip();
|
|
}}
|
|
className="radius-canvas relative flex-1 min-h-[420px] sm:min-h-[560px] xl:min-h-[760px] overflow-hidden border border-zinc-800 bg-zinc-950/80"
|
|
style={{ cursor: topologyDragging ? 'grabbing' : 'grab' }}
|
|
>
|
|
<SpaceParticles />
|
|
<div className="absolute inset-0 z-10">
|
|
<div
|
|
style={{
|
|
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyZoom})`,
|
|
transformOrigin: '0 0',
|
|
width: topologyGraph.width,
|
|
height: topologyGraph.height,
|
|
transition: topologyDragging || draggedNode ? 'none' : 'transform 0.1s ease-out',
|
|
}}
|
|
className="relative will-change-transform"
|
|
>
|
|
<svg
|
|
width={topologyGraph.width}
|
|
height={topologyGraph.height}
|
|
className="block absolute top-0 left-0 overflow-visible"
|
|
>
|
|
<style>
|
|
{`
|
|
@keyframes flow {
|
|
from { stroke-dashoffset: 24; }
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
.animate-flow {
|
|
animation: flow 1s linear infinite;
|
|
}
|
|
.animate-flow-fast {
|
|
animation: flow 0.5s linear infinite;
|
|
}
|
|
`}
|
|
</style>
|
|
{topologyGraph.lines.map((line, idx) => (
|
|
line.hidden ? null : (
|
|
<g key={`line-${idx}`}>
|
|
{/* Faint energy track */}
|
|
<path
|
|
d={line.path}
|
|
fill="none"
|
|
stroke={line.highlighted ? 'var(--topology-line-highlight-track)' : 'var(--topology-line-track)'}
|
|
strokeWidth={line.highlighted ? '6' : '2'}
|
|
strokeLinecap="round"
|
|
className="transition-all duration-300"
|
|
/>
|
|
{/* Flowing light particles */}
|
|
<path
|
|
d={line.path}
|
|
fill="none"
|
|
stroke={line.highlighted ? 'var(--topology-line-highlight-flow)' : 'var(--topology-line-flow)'}
|
|
strokeWidth={line.highlighted ? '2.5' : '1.5'}
|
|
strokeDasharray={line.highlighted ? "6 18" : "4 20"}
|
|
className={line.highlighted ? "animate-flow-fast" : "animate-flow"}
|
|
strokeLinecap="round"
|
|
opacity={line.dimmed ? 0.1 : 1}
|
|
/>
|
|
</g>
|
|
)
|
|
))}
|
|
{topologyGraph.cards.map((card) => (
|
|
card.hidden ? null : <GraphCard key={card.key} card={card} onHover={handleTopologyHover} onLeave={clearTopologyTooltip} onDragStart={handleNodeDragStart} />
|
|
))}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{topologyTooltip && (
|
|
<div
|
|
className="topology-tooltip pointer-events-none fixed z-50 w-[360px] max-w-[min(360px,calc(100vw-24px))] brand-card-subtle border border-zinc-700/80 p-4 backdrop-blur-md transition-opacity duration-200"
|
|
style={{ left: topologyTooltip.x, top: topologyTooltip.y }}
|
|
>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="topology-accent-warning w-2 h-2 rounded-full" />
|
|
<div className="text-sm font-semibold text-zinc-100">{topologyTooltip.title}</div>
|
|
</div>
|
|
<div className="text-xs text-zinc-400 mb-3 pb-3 border-b border-zinc-800/60">{topologyTooltip.subtitle}</div>
|
|
<div className="space-y-1.5">
|
|
{topologyTooltip.meta.map((line, idx) => {
|
|
if (!line.includes('=')) {
|
|
return (
|
|
<div key={idx} className="text-xs text-zinc-300 font-medium">
|
|
{line}
|
|
</div>
|
|
);
|
|
}
|
|
const [key, ...rest] = line.split('=');
|
|
const value = rest.join('=');
|
|
return (
|
|
<div key={idx} className="flex justify-between gap-3 text-xs">
|
|
<span className="text-zinc-500">{key}</span>
|
|
<span className="text-zinc-300 font-medium text-right">{value || '-'}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{topologyTooltip.transportType === 'local' && topologyTooltip.agentID && (
|
|
<div className="mt-4 pt-4 border-t border-zinc-800/60 space-y-3">
|
|
<div className="text-[11px] text-zinc-500 uppercase tracking-wider">{t('internalStream')}</div>
|
|
{streamPreviewByAgent[topologyTooltip.agentID]?.loading ? (
|
|
<div className="text-xs text-zinc-400">Loading internal stream...</div>
|
|
) : streamPreviewByAgent[topologyTooltip.agentID]?.task ? (
|
|
<>
|
|
<div className="brand-card-subtle border border-zinc-800 p-3 space-y-1.5">
|
|
<div className="text-xs text-zinc-300">run={streamPreviewByAgent[topologyTooltip.agentID]?.task?.id || '-'}</div>
|
|
<div className="text-xs text-zinc-400">
|
|
status={streamPreviewByAgent[topologyTooltip.agentID]?.task?.status || '-'} · thread={streamPreviewByAgent[topologyTooltip.agentID]?.task?.thread_id || '-'}
|
|
</div>
|
|
</div>
|
|
{streamPreviewByAgent[topologyTooltip.agentID]?.items?.length ? (
|
|
<div className="brand-card-subtle border border-zinc-800 p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-xs font-medium text-zinc-200">
|
|
{(() => {
|
|
const item = streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1];
|
|
return item.kind === 'event'
|
|
? `${item.event_type || 'event'}${item.status ? ` · ${item.status}` : ''}`
|
|
: `${item.from_agent || '-'} -> ${item.to_agent || '-'} · ${item.message_type || 'message'}`;
|
|
})()}
|
|
</div>
|
|
<div className="text-[11px] text-zinc-500">
|
|
{formatStreamTime(streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1]?.at)}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-zinc-300 leading-5 whitespace-pre-wrap break-words">
|
|
{(() => {
|
|
const item = streamPreviewByAgent[topologyTooltip.agentID].items[streamPreviewByAgent[topologyTooltip.agentID].items.length - 1];
|
|
return summarizePreviewText(item.kind === 'event' ? (item.message || '(no event message)') : (item.content || '(empty message)'), 520);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-zinc-400">No internal stream events yet.</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="text-xs text-zinc-400">No persisted run for this agent yet.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Subagents;
|