app.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import { computeThoughtChainLayout } from "./layout-engine.js";
  2. import { ThoughtChainRenderer } from "./renderer.js";
  3. const svg = document.getElementById("mindmapSvg");
  4. const viewportGroup = document.getElementById("viewportGroup");
  5. const linksLayer = document.getElementById("linksLayer");
  6. const nodesLayer = document.getElementById("nodesLayer");
  7. const canvasWrapper = document.getElementById("canvasWrapper");
  8. const conversationSelect = document.getElementById("conversationSelect");
  9. const refreshBtn = document.getElementById("refreshBtn");
  10. const fitViewBtn = document.getElementById("fitViewBtn");
  11. const resetViewBtn = document.getElementById("resetViewBtn");
  12. const exportSvgBtn = document.getElementById("exportSvgBtn");
  13. const statNodes = document.getElementById("statNodes");
  14. const statEdges = document.getElementById("statEdges");
  15. const statRunning = document.getElementById("statRunning");
  16. const statBug = document.getElementById("statBug");
  17. const debugPanel = document.getElementById("debugPanel");
  18. const renderer = new ThoughtChainRenderer({
  19. svg,
  20. linksLayer,
  21. nodesLayer,
  22. });
  23. const VIEW = {
  24. x: 120,
  25. y: 80,
  26. scale: 1,
  27. minScale: 0.32,
  28. maxScale: 2.7,
  29. };
  30. const STATE = {
  31. conversations: [],
  32. conversationId: "",
  33. graph: { nodes: [], edges: [], summary: {} },
  34. selectedNodeId: "",
  35. layout: null,
  36. stream: null,
  37. refreshTimer: null,
  38. };
  39. let panning = false;
  40. let panStart = { x: 0, y: 0 };
  41. let panOrigin = { x: 0, y: 0 };
  42. function clamp(value, min, max) {
  43. return Math.min(max, Math.max(min, value));
  44. }
  45. function applyViewport() {
  46. viewportGroup.setAttribute("transform", `translate(${VIEW.x} ${VIEW.y}) scale(${VIEW.scale})`);
  47. }
  48. async function api(path) {
  49. const response = await fetch(path, {
  50. headers: { "Content-Type": "application/json" },
  51. });
  52. const payload = await response.json().catch(() => ({}));
  53. if (!response.ok) {
  54. throw new Error(payload.error || `请求失败: ${response.status}`);
  55. }
  56. return payload;
  57. }
  58. function renderConversationSelect() {
  59. conversationSelect.innerHTML = "";
  60. if (!STATE.conversations.length) {
  61. const emptyOption = document.createElement("option");
  62. emptyOption.value = "";
  63. emptyOption.textContent = "暂无会话";
  64. conversationSelect.appendChild(emptyOption);
  65. conversationSelect.disabled = true;
  66. return;
  67. }
  68. conversationSelect.disabled = false;
  69. for (const convo of STATE.conversations) {
  70. const option = document.createElement("option");
  71. option.value = convo.conversationId;
  72. option.textContent = `${convo.title} (${convo.nodeCount})`;
  73. conversationSelect.appendChild(option);
  74. }
  75. if (!STATE.conversationId || !STATE.conversations.some((item) => item.conversationId === STATE.conversationId)) {
  76. STATE.conversationId = STATE.conversations[0].conversationId;
  77. }
  78. conversationSelect.value = STATE.conversationId;
  79. }
  80. function getSelectedNode() {
  81. if (!STATE.selectedNodeId) return null;
  82. return STATE.graph.nodes.find((node) => node.id === STATE.selectedNodeId) || null;
  83. }
  84. function renderDebugPanel() {
  85. const node = getSelectedNode();
  86. if (!node) {
  87. debugPanel.innerHTML = '<p class="empty-text">点击节点查看详情</p>';
  88. return;
  89. }
  90. const diagnostics = node.llmDiagnostics || {};
  91. const tokenUsage = diagnostics.tokenUsage || {};
  92. const created = node.timestamps?.created || "-";
  93. const started = node.timestamps?.started || "-";
  94. const completed = node.timestamps?.completed || "-";
  95. debugPanel.innerHTML = `
  96. <div class="debug-item">
  97. <h3>${node.name}</h3>
  98. <p><strong>ID:</strong> ${node.id}</p>
  99. <p><strong>Type:</strong> ${node.nodeType}</p>
  100. <p><strong>Status:</strong> ${node.status}</p>
  101. <p><strong>Created:</strong> ${created}</p>
  102. <p><strong>Started:</strong> ${started}</p>
  103. <p><strong>Completed:</strong> ${completed}</p>
  104. <p><strong>Stop Reason:</strong> ${diagnostics.stopReason || "-"}</p>
  105. <p><strong>Latency:</strong> ${Number.isFinite(diagnostics.latencyMs) ? `${diagnostics.latencyMs}ms` : "-"}</p>
  106. <p><strong>Prompt Tokens:</strong> ${
  107. Number.isFinite(tokenUsage.promptTokens) ? tokenUsage.promptTokens : "-"
  108. }</p>
  109. <p><strong>Completion Tokens:</strong> ${
  110. Number.isFinite(tokenUsage.completionTokens) ? tokenUsage.completionTokens : "-"
  111. }</p>
  112. <p><strong>Total Tokens:</strong> ${
  113. Number.isFinite(tokenUsage.totalTokens) ? tokenUsage.totalTokens : "-"
  114. }</p>
  115. <p><strong>Content:</strong> ${node.content || "-"}</p>
  116. <p><strong>Notes:</strong> ${node.notes || "-"}</p>
  117. </div>
  118. `;
  119. }
  120. function updateStats() {
  121. const summary = STATE.graph.summary || {};
  122. statNodes.textContent = String(summary.nodeCount || 0);
  123. statEdges.textContent = String(summary.edgeCount || 0);
  124. statRunning.textContent = String(summary.runningCount || 0);
  125. statBug.textContent = String(summary.bugCount || 0);
  126. }
  127. function renderGraph() {
  128. STATE.layout = computeThoughtChainLayout(STATE.graph);
  129. renderer.render(STATE.graph, STATE.layout, {
  130. selectedNodeId: STATE.selectedNodeId,
  131. onNodeClick: (nodeId) => {
  132. STATE.selectedNodeId = nodeId;
  133. renderGraph();
  134. renderDebugPanel();
  135. },
  136. });
  137. updateStats();
  138. renderDebugPanel();
  139. }
  140. function fitView() {
  141. if (!STATE.layout || !STATE.graph.nodes.length) {
  142. VIEW.x = 120;
  143. VIEW.y = 80;
  144. VIEW.scale = 1;
  145. applyViewport();
  146. return;
  147. }
  148. const bounds = STATE.layout.bounds;
  149. const margin = 48;
  150. const width = canvasWrapper.clientWidth || 1;
  151. const height = canvasWrapper.clientHeight || 1;
  152. const scaleX = (width - margin * 2) / Math.max(1, bounds.width);
  153. const scaleY = (height - margin * 2) / Math.max(1, bounds.height);
  154. const nextScale = clamp(Math.min(scaleX, scaleY), VIEW.minScale, 1.4);
  155. VIEW.scale = nextScale;
  156. VIEW.x = (width - bounds.width * nextScale) / 2 - bounds.minX * nextScale;
  157. VIEW.y = (height - bounds.height * nextScale) / 2 - bounds.minY * nextScale;
  158. applyViewport();
  159. }
  160. async function loadConversations() {
  161. STATE.conversations = await api("/api/public/conversations");
  162. renderConversationSelect();
  163. }
  164. async function loadCurrentConversation() {
  165. if (!STATE.conversationId) {
  166. STATE.graph = { nodes: [], edges: [], summary: {} };
  167. STATE.selectedNodeId = "";
  168. renderGraph();
  169. return;
  170. }
  171. STATE.graph = await api(`/api/public/tasks/${encodeURIComponent(STATE.conversationId)}`);
  172. if (!STATE.graph.nodes.some((node) => node.id === STATE.selectedNodeId)) {
  173. STATE.selectedNodeId = "";
  174. }
  175. renderGraph();
  176. }
  177. function scheduleRefresh() {
  178. if (STATE.refreshTimer) clearTimeout(STATE.refreshTimer);
  179. STATE.refreshTimer = setTimeout(async () => {
  180. try {
  181. await loadConversations();
  182. await loadCurrentConversation();
  183. } catch (error) {
  184. console.error(error);
  185. }
  186. }, 220);
  187. }
  188. function connectStream() {
  189. if (STATE.stream) {
  190. STATE.stream.close();
  191. STATE.stream = null;
  192. }
  193. if (!STATE.conversationId) return;
  194. const streamUrl = `/api/public/stream/${encodeURIComponent(STATE.conversationId)}`;
  195. const stream = new EventSource(streamUrl);
  196. stream.addEventListener("conversation_update", (event) => {
  197. try {
  198. const payload = JSON.parse(event.data || "{}");
  199. if (!payload.conversationId || payload.conversationId === STATE.conversationId) {
  200. scheduleRefresh();
  201. }
  202. } catch {
  203. scheduleRefresh();
  204. }
  205. });
  206. stream.addEventListener("error", () => {
  207. stream.close();
  208. if (STATE.stream === stream) {
  209. STATE.stream = null;
  210. setTimeout(() => connectStream(), 1800);
  211. }
  212. });
  213. STATE.stream = stream;
  214. }
  215. async function switchConversation(conversationId, options = {}) {
  216. STATE.conversationId = conversationId || "";
  217. await loadCurrentConversation();
  218. connectStream();
  219. if (options.fit !== false) fitView();
  220. }
  221. async function bootstrap() {
  222. await loadConversations();
  223. await switchConversation(STATE.conversationId, { fit: true });
  224. applyViewport();
  225. }
  226. conversationSelect.addEventListener("change", async (event) => {
  227. const nextConversationId = String(event.target.value || "").trim();
  228. try {
  229. await switchConversation(nextConversationId, { fit: true });
  230. } catch (error) {
  231. window.alert(error.message);
  232. }
  233. });
  234. refreshBtn.addEventListener("click", async () => {
  235. try {
  236. await loadConversations();
  237. await loadCurrentConversation();
  238. } catch (error) {
  239. window.alert(error.message);
  240. }
  241. });
  242. fitViewBtn.addEventListener("click", () => fitView());
  243. resetViewBtn.addEventListener("click", () => {
  244. VIEW.x = 120;
  245. VIEW.y = 80;
  246. VIEW.scale = 1;
  247. applyViewport();
  248. });
  249. exportSvgBtn.addEventListener("click", () => {
  250. const filename = `openclaw-v2-${new Date().toISOString().slice(0, 10)}.svg`;
  251. renderer.exportSvg(filename);
  252. });
  253. canvasWrapper.addEventListener("wheel", (event) => {
  254. event.preventDefault();
  255. const zoomFactor = event.deltaY < 0 ? 1.08 : 0.92;
  256. VIEW.scale = clamp(VIEW.scale * zoomFactor, VIEW.minScale, VIEW.maxScale);
  257. applyViewport();
  258. });
  259. canvasWrapper.addEventListener("pointerdown", (event) => {
  260. if (event.button !== 0) return;
  261. const target = event.target;
  262. if (target && target.closest && target.closest(".node-group")) return;
  263. panning = true;
  264. panStart = { x: event.clientX, y: event.clientY };
  265. panOrigin = { x: VIEW.x, y: VIEW.y };
  266. canvasWrapper.setPointerCapture(event.pointerId);
  267. });
  268. canvasWrapper.addEventListener("pointermove", (event) => {
  269. if (!panning) return;
  270. const dx = event.clientX - panStart.x;
  271. const dy = event.clientY - panStart.y;
  272. VIEW.x = panOrigin.x + dx;
  273. VIEW.y = panOrigin.y + dy;
  274. applyViewport();
  275. });
  276. canvasWrapper.addEventListener("pointerup", (event) => {
  277. panning = false;
  278. canvasWrapper.releasePointerCapture(event.pointerId);
  279. });
  280. canvasWrapper.addEventListener("pointercancel", (event) => {
  281. panning = false;
  282. canvasWrapper.releasePointerCapture(event.pointerId);
  283. });
  284. canvasWrapper.addEventListener("click", (event) => {
  285. if (event.target && event.target.closest && event.target.closest(".node-group")) return;
  286. STATE.selectedNodeId = "";
  287. renderGraph();
  288. });
  289. window.addEventListener("resize", () => {
  290. if (STATE.graph.nodes.length) fitView();
  291. });
  292. bootstrap().catch((error) => {
  293. console.error(error);
  294. window.alert(`初始化失败: ${error.message}`);
  295. });