const STATUS_LABELS = { pending: "待开始", "in-progress": "执行中", completed: "已完成", bug: "Bug", }; function svgEl(tagName, attrs = {}) { const node = document.createElementNS("http://www.w3.org/2000/svg", tagName); Object.entries(attrs).forEach(([key, value]) => { if (value !== undefined && value !== null) node.setAttribute(key, String(value)); }); return node; } function truncate(text, max = 26) { if (!text) return ""; if (text.length <= max) return text; return `${text.slice(0, max - 1)}…`; } function oneLine(text) { return String(text || "").replace(/\s+/g, " ").trim(); } function toPrettyTime(isoValue) { if (!isoValue) return "-"; const date = new Date(isoValue); if (Number.isNaN(date.getTime())) return "-"; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); const h = String(date.getHours()).padStart(2, "0"); const min = String(date.getMinutes()).padStart(2, "0"); return `${y}-${m}-${d} ${h}:${min}`; } function edgePath(from, to) { const c1x = from.x + 92; const c2x = to.x - 92; return `M ${from.x} ${from.y} C ${c1x} ${from.y}, ${c2x} ${to.y}, ${to.x} ${to.y}`; } export class ThoughtChainRenderer { constructor(params) { this.svg = params.svg; this.linksLayer = params.linksLayer; this.nodesLayer = params.nodesLayer; } renderEmpty() { this.linksLayer.innerHTML = ""; this.nodesLayer.innerHTML = ""; const text = svgEl("text", { x: 180, y: 200, class: "empty-state", }); text.textContent = "当前会话暂无节点"; this.nodesLayer.appendChild(text); } render(graph, layout, options = {}) { const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; if (!nodes.length) { this.renderEmpty(); return; } const selectedNodeId = options.selectedNodeId || null; const onNodeClick = typeof options.onNodeClick === "function" ? options.onNodeClick : null; this.linksLayer.innerHTML = ""; this.nodesLayer.innerHTML = ""; for (const link of layout.links) { const path = svgEl("path", { class: `link-path status-${link.status} type-${link.type || "reference"}`, d: edgePath(link.from, link.to), }); this.linksLayer.appendChild(path); } for (const node of nodes) { const pos = layout.positions.get(node.id); if (!pos) continue; const selectedClass = node.id === selectedNodeId ? " selected" : ""; const group = svgEl("g", { class: `node-group status-${node.status}${selectedClass}`, transform: `translate(${pos.x} ${pos.y})`, "data-id": node.id, }); if (node.status === "in-progress") { const ring = svgEl("rect", { class: "progress-ring", x: -146, y: -58, width: 292, height: 116, rx: 24, }); group.appendChild(ring); } const card = svgEl("rect", { class: "node-card", x: -135, y: -50, width: 270, height: 100, rx: 18, }); group.appendChild(card); const statusDot = svgEl("circle", { class: "node-status-dot", cx: -114, cy: -31, r: 7, }); group.appendChild(statusDot); const title = svgEl("text", { class: "node-title", x: -98, y: -27, }); title.textContent = truncate(oneLine(node.name), 22); group.appendChild(title); const line1 = svgEl("text", { class: "node-subtitle", x: -98, y: -5, }); line1.textContent = `${STATUS_LABELS[node.status] || node.status} · ${node.nodeType}`; group.appendChild(line1); const line2 = svgEl("text", { class: "node-subtitle", x: -98, y: 16, }); line2.textContent = toPrettyTime(node.timestamps?.created || node.createdAt); group.appendChild(line2); const line3 = svgEl("text", { class: "node-subtitle muted", x: -98, y: 36, }); line3.textContent = truncate(oneLine(node.content || node.notes || ""), 28) || "无详细内容"; group.appendChild(line3); if (onNodeClick) { group.addEventListener("click", (event) => { event.stopPropagation(); onNodeClick(node.id); }); } this.nodesLayer.appendChild(group); } } exportSvg(filename) { const cloned = this.svg.cloneNode(true); cloned.setAttribute("xmlns", "http://www.w3.org/2000/svg"); cloned.setAttribute("width", String(this.svg.clientWidth || 1600)); cloned.setAttribute("height", String(this.svg.clientHeight || 960)); const source = new XMLSerializer().serializeToString(cloned); const blob = new Blob([source], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } }