import { computeThoughtChainLayout } from "./layout-engine.js"; import { ThoughtChainRenderer } from "./renderer.js"; const svg = document.getElementById("mindmapSvg"); const viewportGroup = document.getElementById("viewportGroup"); const linksLayer = document.getElementById("linksLayer"); const nodesLayer = document.getElementById("nodesLayer"); const canvasWrapper = document.getElementById("canvasWrapper"); const conversationSelect = document.getElementById("conversationSelect"); const refreshBtn = document.getElementById("refreshBtn"); const fitViewBtn = document.getElementById("fitViewBtn"); const resetViewBtn = document.getElementById("resetViewBtn"); const exportSvgBtn = document.getElementById("exportSvgBtn"); const statNodes = document.getElementById("statNodes"); const statEdges = document.getElementById("statEdges"); const statRunning = document.getElementById("statRunning"); const statBug = document.getElementById("statBug"); const debugPanel = document.getElementById("debugPanel"); const renderer = new ThoughtChainRenderer({ svg, linksLayer, nodesLayer, }); const VIEW = { x: 120, y: 80, scale: 1, minScale: 0.32, maxScale: 2.7, }; const STATE = { conversations: [], conversationId: "", graph: { nodes: [], edges: [], summary: {} }, selectedNodeId: "", layout: null, stream: null, refreshTimer: null, }; let panning = false; let panStart = { x: 0, y: 0 }; let panOrigin = { x: 0, y: 0 }; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function applyViewport() { viewportGroup.setAttribute("transform", `translate(${VIEW.x} ${VIEW.y}) scale(${VIEW.scale})`); } async function api(path) { const response = await fetch(path, { headers: { "Content-Type": "application/json" }, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.error || `请求失败: ${response.status}`); } return payload; } function renderConversationSelect() { conversationSelect.innerHTML = ""; if (!STATE.conversations.length) { const emptyOption = document.createElement("option"); emptyOption.value = ""; emptyOption.textContent = "暂无会话"; conversationSelect.appendChild(emptyOption); conversationSelect.disabled = true; return; } conversationSelect.disabled = false; for (const convo of STATE.conversations) { const option = document.createElement("option"); option.value = convo.conversationId; option.textContent = `${convo.title} (${convo.nodeCount})`; conversationSelect.appendChild(option); } if (!STATE.conversationId || !STATE.conversations.some((item) => item.conversationId === STATE.conversationId)) { STATE.conversationId = STATE.conversations[0].conversationId; } conversationSelect.value = STATE.conversationId; } function getSelectedNode() { if (!STATE.selectedNodeId) return null; return STATE.graph.nodes.find((node) => node.id === STATE.selectedNodeId) || null; } function renderDebugPanel() { const node = getSelectedNode(); if (!node) { debugPanel.innerHTML = '

点击节点查看详情

'; return; } const diagnostics = node.llmDiagnostics || {}; const tokenUsage = diagnostics.tokenUsage || {}; const created = node.timestamps?.created || "-"; const started = node.timestamps?.started || "-"; const completed = node.timestamps?.completed || "-"; debugPanel.innerHTML = `

${node.name}

ID: ${node.id}

Type: ${node.nodeType}

Status: ${node.status}

Created: ${created}

Started: ${started}

Completed: ${completed}

Stop Reason: ${diagnostics.stopReason || "-"}

Latency: ${Number.isFinite(diagnostics.latencyMs) ? `${diagnostics.latencyMs}ms` : "-"}

Prompt Tokens: ${ Number.isFinite(tokenUsage.promptTokens) ? tokenUsage.promptTokens : "-" }

Completion Tokens: ${ Number.isFinite(tokenUsage.completionTokens) ? tokenUsage.completionTokens : "-" }

Total Tokens: ${ Number.isFinite(tokenUsage.totalTokens) ? tokenUsage.totalTokens : "-" }

Content: ${node.content || "-"}

Notes: ${node.notes || "-"}

`; } function updateStats() { const summary = STATE.graph.summary || {}; statNodes.textContent = String(summary.nodeCount || 0); statEdges.textContent = String(summary.edgeCount || 0); statRunning.textContent = String(summary.runningCount || 0); statBug.textContent = String(summary.bugCount || 0); } function renderGraph() { STATE.layout = computeThoughtChainLayout(STATE.graph); renderer.render(STATE.graph, STATE.layout, { selectedNodeId: STATE.selectedNodeId, onNodeClick: (nodeId) => { STATE.selectedNodeId = nodeId; renderGraph(); renderDebugPanel(); }, }); updateStats(); renderDebugPanel(); } function fitView() { if (!STATE.layout || !STATE.graph.nodes.length) { VIEW.x = 120; VIEW.y = 80; VIEW.scale = 1; applyViewport(); return; } const bounds = STATE.layout.bounds; const margin = 48; const width = canvasWrapper.clientWidth || 1; const height = canvasWrapper.clientHeight || 1; const scaleX = (width - margin * 2) / Math.max(1, bounds.width); const scaleY = (height - margin * 2) / Math.max(1, bounds.height); const nextScale = clamp(Math.min(scaleX, scaleY), VIEW.minScale, 1.4); VIEW.scale = nextScale; VIEW.x = (width - bounds.width * nextScale) / 2 - bounds.minX * nextScale; VIEW.y = (height - bounds.height * nextScale) / 2 - bounds.minY * nextScale; applyViewport(); } async function loadConversations() { STATE.conversations = await api("/api/public/conversations"); renderConversationSelect(); } async function loadCurrentConversation() { if (!STATE.conversationId) { STATE.graph = { nodes: [], edges: [], summary: {} }; STATE.selectedNodeId = ""; renderGraph(); return; } STATE.graph = await api(`/api/public/tasks/${encodeURIComponent(STATE.conversationId)}`); if (!STATE.graph.nodes.some((node) => node.id === STATE.selectedNodeId)) { STATE.selectedNodeId = ""; } renderGraph(); } function scheduleRefresh() { if (STATE.refreshTimer) clearTimeout(STATE.refreshTimer); STATE.refreshTimer = setTimeout(async () => { try { await loadConversations(); await loadCurrentConversation(); } catch (error) { console.error(error); } }, 220); } function connectStream() { if (STATE.stream) { STATE.stream.close(); STATE.stream = null; } if (!STATE.conversationId) return; const streamUrl = `/api/public/stream/${encodeURIComponent(STATE.conversationId)}`; const stream = new EventSource(streamUrl); stream.addEventListener("conversation_update", (event) => { try { const payload = JSON.parse(event.data || "{}"); if (!payload.conversationId || payload.conversationId === STATE.conversationId) { scheduleRefresh(); } } catch { scheduleRefresh(); } }); stream.addEventListener("error", () => { stream.close(); if (STATE.stream === stream) { STATE.stream = null; setTimeout(() => connectStream(), 1800); } }); STATE.stream = stream; } async function switchConversation(conversationId, options = {}) { STATE.conversationId = conversationId || ""; await loadCurrentConversation(); connectStream(); if (options.fit !== false) fitView(); } async function bootstrap() { await loadConversations(); await switchConversation(STATE.conversationId, { fit: true }); applyViewport(); } conversationSelect.addEventListener("change", async (event) => { const nextConversationId = String(event.target.value || "").trim(); try { await switchConversation(nextConversationId, { fit: true }); } catch (error) { window.alert(error.message); } }); refreshBtn.addEventListener("click", async () => { try { await loadConversations(); await loadCurrentConversation(); } catch (error) { window.alert(error.message); } }); fitViewBtn.addEventListener("click", () => fitView()); resetViewBtn.addEventListener("click", () => { VIEW.x = 120; VIEW.y = 80; VIEW.scale = 1; applyViewport(); }); exportSvgBtn.addEventListener("click", () => { const filename = `openclaw-v2-${new Date().toISOString().slice(0, 10)}.svg`; renderer.exportSvg(filename); }); canvasWrapper.addEventListener("wheel", (event) => { event.preventDefault(); const zoomFactor = event.deltaY < 0 ? 1.08 : 0.92; VIEW.scale = clamp(VIEW.scale * zoomFactor, VIEW.minScale, VIEW.maxScale); applyViewport(); }); canvasWrapper.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const target = event.target; if (target && target.closest && target.closest(".node-group")) return; panning = true; panStart = { x: event.clientX, y: event.clientY }; panOrigin = { x: VIEW.x, y: VIEW.y }; canvasWrapper.setPointerCapture(event.pointerId); }); canvasWrapper.addEventListener("pointermove", (event) => { if (!panning) return; const dx = event.clientX - panStart.x; const dy = event.clientY - panStart.y; VIEW.x = panOrigin.x + dx; VIEW.y = panOrigin.y + dy; applyViewport(); }); canvasWrapper.addEventListener("pointerup", (event) => { panning = false; canvasWrapper.releasePointerCapture(event.pointerId); }); canvasWrapper.addEventListener("pointercancel", (event) => { panning = false; canvasWrapper.releasePointerCapture(event.pointerId); }); canvasWrapper.addEventListener("click", (event) => { if (event.target && event.target.closest && event.target.closest(".node-group")) return; STATE.selectedNodeId = ""; renderGraph(); }); window.addEventListener("resize", () => { if (STATE.graph.nodes.length) fitView(); }); bootstrap().catch((error) => { console.error(error); window.alert(`初始化失败: ${error.message}`); });