feat: unify websocket runtime and harden node control

This commit is contained in:
lpf
2026-03-08 22:22:49 +08:00
parent 7e67619826
commit 4172a57b39
15 changed files with 2082 additions and 124 deletions

View File

@@ -1,6 +1,29 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { CronJob, Cfg, Session, Skill } from '../types';
type RuntimeSnapshot = {
version?: {
gateway_version?: string;
webui_version?: string;
};
nodes?: {
nodes?: any[];
trees?: any[];
};
sessions?: {
sessions?: Array<{ key: string; title?: string; channel?: string }>;
};
task_queue?: {
items?: any[];
};
ekg?: Record<string, any>;
subagents?: {
items?: any[];
registry?: any[];
stream?: any[];
};
};
interface AppContextType {
token: string;
sidebarOpen: boolean;
@@ -32,6 +55,12 @@ interface AppContextType {
setTaskQueueItems: React.Dispatch<React.SetStateAction<any[]>>;
ekgSummary: Record<string, any>;
setEkgSummary: React.Dispatch<React.SetStateAction<Record<string, any>>>;
subagentRuntimeItems: any[];
setSubagentRuntimeItems: React.Dispatch<React.SetStateAction<any[]>>;
subagentRegistryItems: any[];
setSubagentRegistryItems: React.Dispatch<React.SetStateAction<any[]>>;
subagentStreamItems: any[];
setSubagentStreamItems: React.Dispatch<React.SetStateAction<any[]>>;
refreshAll: () => Promise<void>;
refreshCron: () => Promise<void>;
refreshNodes: () => Promise<void>;
@@ -81,6 +110,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [sessions, setSessions] = useState<Session[]>([{ key: 'main', title: 'main' }]);
const [taskQueueItems, setTaskQueueItems] = useState<any[]>([]);
const [ekgSummary, setEkgSummary] = useState<Record<string, any>>({});
const [subagentRuntimeItems, setSubagentRuntimeItems] = useState<any[]>([]);
const [subagentRegistryItems, setSubagentRegistryItems] = useState<any[]>([]);
const [subagentStreamItems, setSubagentStreamItems] = useState<any[]>([]);
const [gatewayVersion, setGatewayVersion] = useState('unknown');
const [webuiVersion, setWebuiVersion] = useState('unknown');
const [hotReloadFields, setHotReloadFields] = useState<string[]>([]);
@@ -218,17 +250,86 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
useEffect(() => {
refreshAll();
const interval = setInterval(() => {
loadConfig();
refreshCron();
refreshNodes();
refreshSkills();
refreshSessions();
refreshVersion();
refreshTaskQueue();
refreshEKGSummary();
}, 10000);
return () => clearInterval(interval);
}, [token, refreshAll]);
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let retryTimer: number | null = null;
const applySnapshot = (snapshot: RuntimeSnapshot) => {
if (snapshot.version) {
setGatewayVersion(snapshot.version.gateway_version || 'unknown');
setWebuiVersion(snapshot.version.webui_version || 'unknown');
}
if (snapshot.nodes) {
setNodes(JSON.stringify(Array.isArray(snapshot.nodes.nodes) ? snapshot.nodes.nodes : [], null, 2));
setNodeTrees(JSON.stringify(Array.isArray(snapshot.nodes.trees) ? snapshot.nodes.trees : [], null, 2));
}
if (snapshot.sessions) {
const arr = Array.isArray(snapshot.sessions.sessions) ? snapshot.sessions.sessions : [];
setSessions(arr.map((s) => ({ key: s.key, title: s.title || s.key })));
}
if (snapshot.task_queue) {
setTaskQueueItems(Array.isArray(snapshot.task_queue.items) ? snapshot.task_queue.items : []);
}
if (snapshot.ekg && typeof snapshot.ekg === 'object') {
setEkgSummary(snapshot.ekg);
}
if (snapshot.subagents) {
setSubagentRuntimeItems(Array.isArray(snapshot.subagents.items) ? snapshot.subagents.items : []);
setSubagentRegistryItems(Array.isArray(snapshot.subagents.registry) ? snapshot.subagents.registry : []);
setSubagentStreamItems(Array.isArray(snapshot.subagents.stream) ? snapshot.subagents.stream : []);
}
};
const connect = () => {
try {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${proto}//${window.location.host}/webui/api/runtime`);
if (token) url.searchParams.set('token', token);
socket = new WebSocket(url.toString());
socket.onopen = () => {
setIsGatewayOnline(true);
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg?.type === 'runtime_snapshot' && msg.snapshot) {
applySnapshot(msg.snapshot as RuntimeSnapshot);
setIsGatewayOnline(true);
}
} catch (err) {
console.error(err);
}
};
socket.onerror = () => {
setIsGatewayOnline(false);
};
socket.onclose = () => {
socket = null;
if (disposed) return;
setIsGatewayOnline(false);
retryTimer = window.setTimeout(connect, 3000);
};
} catch (err) {
console.error(err);
setIsGatewayOnline(false);
retryTimer = window.setTimeout(connect, 3000);
}
};
connect();
return () => {
disposed = true;
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
if (socket) {
socket.close();
}
};
}, [token, refreshAll, loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion, refreshTaskQueue, refreshEKGSummary]);
useEffect(() => {
@@ -246,6 +347,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions,
taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary,
subagentRuntimeItems, setSubagentRuntimeItems, subagentRegistryItems, setSubagentRegistryItems, subagentStreamItems, setSubagentStreamItems,
refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshTaskQueue, refreshEKGSummary, refreshVersion, loadConfig,
gatewayVersion, webuiVersion, hotReloadFields, hotReloadFieldDetails, q
}}>

View File

@@ -110,7 +110,7 @@ function collectActors(items: StreamItem[]): string[] {
const Chat: React.FC = () => {
const { t } = useTranslation();
const { q, sessions } = useAppContext();
const { q, sessions, subagentRuntimeItems, subagentRegistryItems, subagentStreamItems } = useAppContext();
const ui = useUI();
const [mainChat, setMainChat] = useState<RenderedChatItem[]>([]);
const [subagentStream, setSubagentStream] = useState<StreamItem[]>([]);
@@ -204,6 +204,10 @@ const Chat: React.FC = () => {
const loadSubagentGroup = async () => {
try {
if (subagentStreamItems.length > 0) {
setSubagentStream(subagentStreamItems);
return;
}
shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'subagents';
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
@@ -221,6 +225,14 @@ const Chat: React.FC = () => {
const loadRegistryAgents = async () => {
try {
if (subagentRegistryItems.length > 0) {
const filtered = subagentRegistryItems.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false);
setRegistryAgents(filtered);
if (!dispatchAgentID && filtered.length > 0) {
setDispatchAgentID(String(filtered[0].agent_id || ''));
}
return;
}
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -241,6 +253,10 @@ const Chat: React.FC = () => {
const loadRuntimeTasks = async () => {
try {
if (subagentRuntimeItems.length > 0) {
setRuntimeTasks(subagentRuntimeItems);
return;
}
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -292,16 +308,10 @@ const Chat: React.FC = () => {
if (input) input.value = '';
try {
const response = await fetch(`/webui/api/chat/stream${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: sessionKey, message: currentMsg, media }),
});
if (!response.ok || !response.body) throw new Error('Chat request failed');
const reader = response.body.getReader();
const decoder = new TextDecoder();
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${proto}//${window.location.host}/webui/api/chat/live`);
const token = new URLSearchParams(q.startsWith('?') ? q.slice(1) : q).get('token');
if (token) url.searchParams.set('token', token);
let assistantText = '';
setMainChat((prev) => [...prev, {
@@ -314,20 +324,55 @@ const Chat: React.FC = () => {
avatarClassName: 'bg-emerald-600/80 text-white',
}]);
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
assistantText += chunk;
setMainChat((prev) => {
const next = [...prev];
next[next.length - 1] = {
...next[next.length - 1],
text: assistantText,
};
return next;
});
}
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(url.toString());
let settled = false;
ws.onopen = () => {
ws.send(JSON.stringify({ session: sessionKey, message: currentMsg, media }));
};
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === 'chat_chunk' && typeof payload?.delta === 'string') {
assistantText += payload.delta;
setMainChat((prev) => {
const next = [...prev];
next[next.length - 1] = {
...next[next.length - 1],
text: assistantText,
};
return next;
});
return;
}
if (payload?.type === 'chat_done') {
settled = true;
ws.close();
resolve();
return;
}
if (payload?.type === 'chat_error') {
settled = true;
ws.close();
reject(new Error(payload?.error || 'Chat request failed'));
}
} catch (e) {
settled = true;
ws.close();
reject(e);
}
};
ws.onerror = () => {
settled = true;
ws.close();
reject(new Error('Chat request failed'));
};
ws.onclose = () => {
if (!settled && !assistantText) {
reject(new Error('Chat request failed'));
}
};
});
loadHistory();
} catch (e) {
@@ -354,16 +399,7 @@ const Chat: React.FC = () => {
loadSubagentGroup();
loadRegistryAgents();
loadRuntimeTasks();
}, [q, chatTab, sessionKey]);
useEffect(() => {
if (chatTab !== 'subagents') return;
const timer = window.setInterval(() => {
loadSubagentGroup();
loadRuntimeTasks();
}, 5000);
return () => window.clearInterval(timer);
}, [q, chatTab]);
}, [q, chatTab, sessionKey, subagentRuntimeItems, subagentRegistryItems, subagentStreamItems]);
const userSessions = (sessions || []).filter((s: any) => !String(s?.key || '').startsWith('subagent:'));
@@ -538,7 +574,7 @@ const Chat: React.FC = () => {
</select>
)}
</div>
<button onClick={chatTab === 'main' ? loadHistory : loadSubagentGroup} className="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-xl bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3" />{t('reloadHistory')}</button>
<button onClick={() => { if (chatTab === 'main') { void loadHistory(); } else { void loadSubagentGroup(); } }} className="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-xl bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3" />{t('reloadHistory')}</button>
</div>
{chatTab === 'subagents' && (

View File

@@ -15,7 +15,7 @@ const Logs: React.FC = () => {
const [isStreaming, setIsStreaming] = useState(true);
const [showRaw, setShowRaw] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const loadRecent = async () => {
try {
@@ -30,42 +30,39 @@ const Logs: React.FC = () => {
}
};
const startStreaming = async () => {
if (abortControllerRef.current) abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
const closeSocket = () => {
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
};
try {
const response = await fetch(`/webui/api/logs/stream${q}`, {
signal: abortControllerRef.current.signal,
});
const startStreaming = () => {
closeSocket();
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${proto}//${window.location.host}/webui/api/logs/live`);
const token = new URLSearchParams(q.startsWith('?') ? q.slice(1) : q).get('token');
if (token) url.searchParams.set('token', token);
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.trim());
lines.forEach(line => {
try {
const log = normalizeLog(JSON.parse(line));
setLogs(prev => [...prev.slice(-1000), log]);
} catch (e) {
// Fallback for non-JSON logs
setLogs(prev => [...prev.slice(-1000), normalizeLog({ time: new Date().toISOString(), level: 'INFO', msg: line })]);
}
});
}
} catch (e: any) {
if (e.name !== 'AbortError') {
const ws = new WebSocket(url.toString());
socketRef.current = ws;
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
const log = normalizeLog(payload?.entry ?? payload);
setLogs(prev => [...prev.slice(-1000), log]);
} catch (e) {
console.error('L0097', e);
}
}
};
ws.onerror = (e) => {
console.error('L0097', e);
};
ws.onclose = () => {
if (socketRef.current === ws) {
socketRef.current = null;
}
};
};
const loadCodeMap = async () => {
@@ -100,11 +97,11 @@ const Logs: React.FC = () => {
if (isStreaming) {
startStreaming();
} else {
if (abortControllerRef.current) abortControllerRef.current.abort();
closeSocket();
}
return () => {
if (abortControllerRef.current) abortControllerRef.current.abort();
closeSocket();
};
}, [isStreaming, q]);

View File

@@ -233,6 +233,14 @@ function summarizePreviewText(value?: string, limit = 180): string {
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}`;
@@ -352,7 +360,7 @@ function GraphCard({
const Subagents: React.FC = () => {
const { t } = useTranslation();
const { q, nodeTrees } = useAppContext();
const { q, nodeTrees, subagentRuntimeItems, subagentRegistryItems } = useAppContext();
const ui = useUI();
const [items, setItems] = useState<SubagentTask[]>([]);
@@ -426,6 +434,24 @@ const Subagents: React.FC = () => {
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')),
@@ -459,14 +485,7 @@ const Subagents: React.FC = () => {
useEffect(() => {
load().catch(() => { });
}, [q, selectedAgentID]);
useEffect(() => {
const timer = window.setInterval(() => {
load().catch(() => { });
}, 5000);
return () => window.clearInterval(timer);
}, [q, selectedAgentID]);
}, [q, selectedAgentID, subagentRuntimeItems, subagentRegistryItems]);
const selected = useMemo(() => items.find((x) => x.id === selectedId) || null, [items, selectedId]);
const parsedNodeTrees = useMemo<NodeTree[]>(() => {
@@ -947,10 +966,6 @@ const Subagents: React.FC = () => {
} catch (e) { }
};
useEffect(() => {
loadThreadAndInbox(selected).catch(() => { });
}, [selectedId, q, items]);
const loadStreamPreview = async (agentID: string, task: SubagentTask | null) => {
const taskID = task?.id || '';
if (!agentID) return;
@@ -1000,10 +1015,81 @@ const Subagents: React.FC = () => {
};
useEffect(() => {
if (!topologyTooltip?.agentID || topologyTooltip.transportType !== 'local') return;
const latestTask = recentTaskByAgent[topologyTooltip.agentID] || null;
loadStreamPreview(topologyTooltip.agentID, latestTask).catch(() => { });
}, [topologyTooltip?.agentID, topologyTooltip?.transportType, recentTaskByAgent, q]);
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">