mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-17 17:47:33 +08:00
feat: unify websocket runtime and harden node control
This commit is contained in:
36
webui/package-lock.json
generated
36
webui/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "clawgo-webui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/ws": "^8.18.1",
|
||||
"express": "^4.21.2",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
@@ -17,7 +18,8 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
@@ -1226,7 +1228,6 @@
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1299,6 +1300,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||
@@ -3380,7 +3390,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -4024,6 +4033,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"start": "node server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/ws": "^8.18.1",
|
||||
"express": "^4.21.2",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
@@ -21,7 +22,8 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -3,10 +3,14 @@ import { createServer as createViteServer } from "vite";
|
||||
import { EventEmitter } from "events";
|
||||
import multer from "multer";
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
const logEmitter = new EventEmitter();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// In-memory only for local dev fallback (no sqlite persistence)
|
||||
const mem = {
|
||||
@@ -118,6 +122,8 @@ app.post("/webui/api/skills", (req, res) => {
|
||||
});
|
||||
|
||||
app.get("/webui/api/logs/stream", (req, res) => {
|
||||
res.setHeader("Deprecation", "true");
|
||||
res.setHeader("X-Clawgo-Replaced-By", "/webui/api/logs/live");
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
@@ -130,6 +136,8 @@ app.get("/webui/api/logs/stream", (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/webui/api/chat/stream", async (req, res) => {
|
||||
res.setHeader("Deprecation", "true");
|
||||
res.setHeader("X-Clawgo-Replaced-By", "/webui/api/chat/live");
|
||||
const { message } = req.body || {};
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
@@ -141,6 +149,48 @@ app.post("/webui/api/chat/stream", async (req, res) => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
wss.on("connection", (socket, req) => {
|
||||
if (!req.url) return;
|
||||
|
||||
if (req.url.startsWith("/webui/api/logs/live")) {
|
||||
const onLog = (entry: any) => {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ ok: true, type: "log_entry", entry }));
|
||||
}
|
||||
};
|
||||
logEmitter.on("log", onLog);
|
||||
onLog({ time: new Date().toISOString(), level: "INFO", msg: "Log stream connected" });
|
||||
socket.on("close", () => {
|
||||
logEmitter.off("log", onLog);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/webui/api/chat/live")) {
|
||||
socket.on("message", async (payload) => {
|
||||
let message = "";
|
||||
try {
|
||||
const body = JSON.parse(String(payload || "{}"));
|
||||
message = String(body?.message || "");
|
||||
} catch {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ ok: false, type: "chat_error", error: "invalid json" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const words = `Simulated streaming response: ${message}`.split(" ");
|
||||
for (const word of words) {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
socket.send(JSON.stringify({ ok: true, type: "chat_chunk", delta: `${word} ` }));
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ ok: true, type: "chat_done" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" });
|
||||
app.use(vite.middlewares);
|
||||
@@ -151,7 +201,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
addLog("INFO", "Gateway WebUI Server started");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user