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

@@ -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">