| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- 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 = '<p class="empty-text">点击节点查看详情</p>';
- 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 = `
- <div class="debug-item">
- <h3>${node.name}</h3>
- <p><strong>ID:</strong> ${node.id}</p>
- <p><strong>Type:</strong> ${node.nodeType}</p>
- <p><strong>Status:</strong> ${node.status}</p>
- <p><strong>Created:</strong> ${created}</p>
- <p><strong>Started:</strong> ${started}</p>
- <p><strong>Completed:</strong> ${completed}</p>
- <p><strong>Stop Reason:</strong> ${diagnostics.stopReason || "-"}</p>
- <p><strong>Latency:</strong> ${Number.isFinite(diagnostics.latencyMs) ? `${diagnostics.latencyMs}ms` : "-"}</p>
- <p><strong>Prompt Tokens:</strong> ${
- Number.isFinite(tokenUsage.promptTokens) ? tokenUsage.promptTokens : "-"
- }</p>
- <p><strong>Completion Tokens:</strong> ${
- Number.isFinite(tokenUsage.completionTokens) ? tokenUsage.completionTokens : "-"
- }</p>
- <p><strong>Total Tokens:</strong> ${
- Number.isFinite(tokenUsage.totalTokens) ? tokenUsage.totalTokens : "-"
- }</p>
- <p><strong>Content:</strong> ${node.content || "-"}</p>
- <p><strong>Notes:</strong> ${node.notes || "-"}</p>
- </div>
- `;
- }
- 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}`);
- });
|