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}`);
});