ChatGPT Message Graph (SVG Icons v4.6.2)

v4.6.2: Replaced text-based toggle icons with consistent SVG icons for a cleaner, professional UI. Retains all previous features (Horizontal Scroll, Priority Render).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ChatGPT Message Graph (SVG Icons v4.6.2)
// @namespace    http://tampermonkey.net/
// @version      4.6.2
// @license      MIT
// @description  v4.6.2: Replaced text-based toggle icons with consistent SVG icons for a cleaner, professional UI. Retains all previous features (Horizontal Scroll, Priority Render).
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  // ==================================================================================
  // 🛠️ CONFIGURATION
  // ==================================================================================
  const CONFIG = {
      panelWidth: 540,
      nodeWidth: 460, 
      baseNodeHeight: 42,
      nodeGap: 14,
      indentPx: 32,
      maxVisibleLines: 9,
      selectionTTLms: 20000,
      debug: false 
  };

  const now = () => Date.now();
  let isCollapsed = localStorage.getItem("GPT_GRAPH_COLLAPSED") === "true"; 

  // --- Graph State ---
  let graph = {
    nodes: new Map(),
    edges: [],
    order: [],
    branchRootId: null
  };
  
  window.__GPT_GRAPH__ = graph;

  // --- Icons (SVG) ---
  const ICONS = {
      // 减号 (Minimize)
      MINIMIZE: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
      // 加号 (Expand)
      EXPAND: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`
  };

  // --- React Fiber ---
  function getReactFiber(dom) {
      const key = Object.keys(dom).find(key => key.startsWith("__reactFiber$"));
      return key ? dom[key] : null;
  }
  
  function getQuoteIdFromReact(userEl) {
      const quoteBtn = userEl.querySelector('button .line-clamp-3')?.closest('button');
      if (!quoteBtn) return null;
      let curr = getReactFiber(quoteBtn);
      if (!curr) return null;
      for(let i=0; i<25; i++) {
          const props = curr.memoizedProps;
          if (props?.message?.metadata?.targeted_reply_source_message_id) {
              return props.message.metadata.targeted_reply_source_message_id;
          }
          curr = curr.return;
          if (!curr) break;
      }
      return null;
  }

  // --- Outline Extraction ---
  function extractOutline(role, el) {
      if (!el) return [{type:'SUM', text:"(error)"}];
      const markdown = el.querySelector("[class*='markdown']") || el.querySelector(".prose");

      if (role === 'user') {
          const bubble = el.querySelector('.whitespace-pre-wrap');
          const rawText = bubble ? bubble.textContent : el.textContent;
          const lines = (rawText || "").trim().split('\n').filter(l=>l.trim());
          return lines.length > 0 ? [{type:'SUM', text:lines[0].slice(0, 65)}] : [{type:'SUM', text:"(empty)"}];
      }

      if (markdown) {
          const items = [];
          
          const firstChild = markdown.firstElementChild;
          const isFirstElemHeader = firstChild && /^H[1-6]$/.test(firstChild.tagName);
          if (firstChild && !isFirstElemHeader) {
              let summary = firstChild.textContent.trim();
              summary = summary.replace(/^结论[::]/, "").replace(/^Summary[::]/, "");
              if (summary.length > 2) items.push({type:'SUM', text:summary.slice(0, 60)});
          }

          const allHeaders = Array.from(markdown.querySelectorAll("h1, h2, h3, h4, h5, h6"));
          const validHeaders = allHeaders.filter(h => !h.closest('pre'));

          if (validHeaders.length > 0) {
              const levels = validHeaders.map(h => parseInt(h.tagName.substring(1)));
              const minLevel = Math.min(...levels);
              validHeaders.forEach(h => {
                  const txt = h.textContent.trim();
                  if (!txt) return;
                  const currentLevel = parseInt(h.tagName.substring(1));
                  if (currentLevel === minLevel) items.push({type:'MAIN', text:txt});
                  else if (currentLevel === minLevel + 1) items.push({type:'SUB', text:txt});
              });
          }

          const mainCount = items.filter(i => i.type === 'MAIN').length;
          if (mainCount < 2) {
              const strongs = Array.from(markdown.querySelectorAll("p > strong:first-child, li > strong:first-child"));
              strongs.forEach(s => {
                  const txt = s.textContent.trim();
                  if (!txt) return;
                  const looksLikeHeader = /^\d+[\.\)]|^(Step|Phase|Case|Note)\s+\d+|.{2,10}[::]$/.test(txt);
                  const exists = items.some(i => i.text.includes(txt));
                  if (!exists && looksLikeHeader) items.push({type:'MAIN', text:txt.replace(/[::]$/, "")});
              });
          }
          
          if (items.length === 0) {
              const fallback = markdown.textContent.trim().slice(0, 60);
              if(fallback) items.push({type:'SUM', text:fallback});
          }
          return items.length > 0 ? items : [{type:'SUM', text:"..."}];
      }
      return [{type:'SUM', text:el.textContent.slice(0, 60)}];
  }

  // --- Node Management ---
  function ensureNode(el) {
    const role = el.getAttribute("data-message-author-role");
    const uuid = el.getAttribute("data-message-id");
    const id = uuid || el.id || `tmp_${Math.random().toString(36)}`;
    
    if (graph.nodes.has(id)) {
        const node = graph.nodes.get(id);
        node.el = el;
        return id;
    }

    let rawText = role === 'user' 
        ? (el.querySelector('.whitespace-pre-wrap')?.textContent || el.textContent)
        : (el.querySelector('.markdown')?.textContent || el.textContent);

    let outline = [];
    try { outline = extractOutline(role, el); } 
    catch(e) { outline = [{type:'SUM', text:"(error)"}]; }

    graph.nodes.set(id, {
      id, role, el,
      outline: outline,
      rawText: rawText || "",
      depth: 0,
      height: CONFIG.baseNodeHeight 
    });
    graph.order.push(id);
    return id;
  }

  function addEdge(from, to, type) {
    if (!from || !to || from === to) return;
    if (!graph.edges.some(e => e.to === to)) graph.edges.push({ from, to, type });
  }

  // --- Rebuild ---
  function rebuildGraph() {
    const msgs = document.querySelectorAll('[data-message-author-role]');
    
    if (msgs.length === 0 && graph.nodes.size > 0) {
        graph.nodes.clear(); graph.edges = []; graph.order = [];
        render(); return;
    }

    const seenIds = new Set();
    msgs.forEach(el => {
        const id = ensureNode(el);
        seenIds.add(id);
    });

    for (const [id, node] of graph.nodes) {
        if (!seenIds.has(id)) {
            graph.nodes.delete(id);
            graph.edges = graph.edges.filter(e => e.from !== id && e.to !== id);
            graph.order = graph.order.filter(oid => oid !== id);
        }
    }

    graph.edges = []; 
    
    for (let i = 0; i < graph.order.length; i++) {
        const currId = graph.order[i];
        const curr = graph.nodes.get(currId);
        if (!curr) continue;

        if (curr.role === 'user' && curr.el) {
            const directParentId = getQuoteIdFromReact(curr.el);
            if (directParentId && graph.nodes.has(directParentId)) {
                addEdge(directParentId, currId, "SELECTION_FOLLOW_UP");
            } else if (i > 0) {
                const prevId = graph.order[i-1];
                if (graph.nodes.has(prevId)) addEdge(prevId, currId, "CONTINUE");
            }
        }

        if (curr.role === 'assistant' && i > 0) {
            const prevId = graph.order[i-1];
            const prev = graph.nodes.get(prevId);
            if (prev && prev.role === 'user') {
                addEdge(prevId, currId, "ASK_PAIR");
            }
        }
    }

    calcDepth();
    render();
  }

  function calcDepth() {
      const parents = new Map();
      graph.edges.forEach(e => {
          if (e.type === "SELECTION_FOLLOW_UP" || e.type === "ASK_PAIR" || e.type === "CONTINUE") {
              parents.set(e.to, {id: e.from, type: e.type});
          }
      });
      const memo = new Map();
      function getD(id) {
          if (memo.has(id)) return memo.get(id);
          if (!parents.has(id)) return 0;
          const p = parents.get(id);
          const cn = graph.nodes.get(id);
          let d = getD(p.id);
          if (p.type === "SELECTION_FOLLOW_UP" && cn && cn.role === 'user') d += 1;
          memo.set(id, d);
          return d;
      }
      graph.nodes.forEach(n => n.depth = getD(n.id));
  }

  // --- UI Render ---
  const ui = document.createElement('div');
  ui.style.cssText = `position:fixed;top:20px;right:20px;width:${CONFIG.panelWidth}px;bottom:20px;z-index:9999;pointer-events:none;display:flex;flex-direction:column;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;transition:height 0.3s ease, bottom 0.3s ease;`;
  
  const canvas = document.createElement('div');
  canvas.style.cssText = `pointer-events:auto;flex:1;background:#fff;border:1px solid #e5e7eb;border-radius:12px;display:flex;flex-direction:column;box-shadow:0 10px 30px rgba(0,0,0,0.1);overflow:hidden;transition:all 0.3s ease;`;
  
  const header = document.createElement('div');
  header.style.cssText = `padding:10px 16px;background:#fff;border-bottom:1px solid #f3f4f6;display:flex;justify-content:space-between;align-items:center;user-select:none;cursor:pointer;`;
  header.ondblclick = () => toggleCollapse();

  // [NEW] Use a span to hold the SVG icon
  header.innerHTML = `
      <div style="display:flex;align-items:center;gap:10px;">
          <button id="tm_toggle" style="width:24px;height:24px;border:none;background:#f3f4f6;border-radius:4px;cursor:pointer;color:#555;display:flex;align-items:center;justify-content:center;transition:background 0.2s;">${ICONS.MINIMIZE}</button>
          <span style="font-weight:700;color:#333;font-size:13px;">Graph v4.6.2</span>
      </div>
      <div style="display:flex;gap:8px;">
          <button id="tm_export" style="font-size:11px;padding:4px 8px;border:1px solid #ddd;border-radius:4px;cursor:pointer;background:white;color:#555;">JSON</button>
          <button id="tm_rebuild" style="font-size:11px;padding:4px 8px;border:1px solid #ddd;border-radius:4px;cursor:pointer;background:white;color:#555;">Refresh</button>
      </div>
  `;
  
  const graphArea = document.createElement('div');
  graphArea.style.cssText = `flex:1;overflow:auto;position:relative;padding:20px;background:#f9fafb;opacity:1;transition:opacity 0.2s ease;`;
  graphArea.id = "tm_graph_area";

  const style = document.createElement('style');
  style.innerHTML = `
    #tm_graph_area::-webkit-scrollbar { width: 8px; height: 8px; }
    #tm_graph_area::-webkit-scrollbar-track { background: transparent; }
    #tm_graph_area::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; }
    .tm-node { transition: all 0.2s ease; }
    .tm-node:hover { transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); z-index:10; border-color: #94a3b8 !important; }
    #tm_toggle:hover { background: #e5e7eb !important; color: #111; }
  `;
  document.head.appendChild(style);

  canvas.appendChild(header);
  canvas.appendChild(graphArea);
  ui.appendChild(canvas);
  document.body.appendChild(ui);

  const toggleBtn = header.querySelector('#tm_toggle');
  
  function updateUIState() {
      if (isCollapsed) {
          ui.style.bottom = "auto";
          ui.style.height = "auto";
          canvas.style.height = "auto";
          graphArea.style.display = "none";
          canvas.style.opacity = "0.9";
          toggleBtn.innerHTML = ICONS.EXPAND; // + Icon
          toggleBtn.title = "Expand";
      } else {
          ui.style.bottom = "20px";
          ui.style.height = "auto"; 
          canvas.style.height = "100%";
          graphArea.style.display = "block";
          canvas.style.opacity = "1";
          toggleBtn.innerHTML = ICONS.MINIMIZE; // - Icon
          toggleBtn.title = "Minimize";
      }
      localStorage.setItem("GPT_GRAPH_COLLAPSED", isCollapsed);
  }

  function toggleCollapse() {
      isCollapsed = !isCollapsed;
      updateUIState();
  }
  
  toggleBtn.onclick = toggleCollapse;
  updateUIState(); 

  function render() {
      if (isCollapsed) return;

      graphArea.innerHTML = "";
      const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
      svg.style.cssText = "position:absolute;top:0;left:0;width:100%;pointer-events:none;z-index:10;";
      graphArea.appendChild(svg);
      
      let y = 20;
      let maxContentWidth = 0; 
      const pos = new Map();

      graph.order.forEach(id => {
          const n = graph.nodes.get(id);
          if (!n) return;
          
          const x = n.depth * CONFIG.indentPx;
          const isU = n.role === 'user';
          
          const allItems = n.outline || [];
          let displayItems = [];
          let hiddenCount = 0;

          if (isU || allItems.length <= CONFIG.maxVisibleLines) {
              displayItems = allItems;
          } else {
              const mandatory = allItems.filter(i => i.type !== 'SUB');
              const maxSlots = CONFIG.maxVisibleLines - 1;
              if (mandatory.length >= maxSlots) {
                  displayItems = mandatory.slice(0, maxSlots);
              } else {
                  let slotsLeft = maxSlots;
                  const finalSet = new Set();
                  allItems.forEach((item, idx) => {
                      if (item.type !== 'SUB') { finalSet.add(idx); slotsLeft--; }
                  });
                  if (slotsLeft > 0) {
                      for(let idx=0; idx<allItems.length; idx++) {
                          if (allItems[idx].type === 'SUB' && slotsLeft > 0) { finalSet.add(idx); slotsLeft--; }
                      }
                  }
                  displayItems = allItems.filter((_, idx) => finalSet.has(idx));
              }
              hiddenCount = allItems.length - displayItems.length;
          }

          let contentH = displayItems.length * 20;
          if (hiddenCount > 0) contentH += 16;
          if (contentH < 20) contentH = 20;
          const boxHeight = contentH + 24; 
          n.height = boxHeight;

          const item = document.createElement('div');
          item.className = "tm-node";
          
          const bg = '#ffffff';
          const borderLeft = isU ? '3px solid #3b82f6' : '3px solid #10b981';
          const nodeW = CONFIG.nodeWidth;
          
          const currentRightEdge = x + nodeW + 40; 
          if (currentRightEdge > maxContentWidth) maxContentWidth = currentRightEdge;

          item.style.cssText = `
              position:absolute;left:${x}px;top:${y}px;
              width:${nodeW}px;
              height:${boxHeight}px;
              background:${bg};
              border: 1px solid #e5e7eb;
              border-left:${borderLeft};
              border-radius:6px; 
              padding:12px 14px;
              font-size:13px; color:#374151;
              box-shadow: 0 1px 2px rgba(0,0,0,0.05);
              cursor:pointer; display:flex; flex-direction:column; justify-content:center;
              z-index:1; box-sizing: border-box;
          `;
          
          item.title = n.rawText.slice(0, 1000);
          item.onclick = () => n.el && n.el.scrollIntoView({behavior:"smooth", block:"center"});
          
          let html = "";
          displayItems.forEach((itemObj) => {
              const txt = itemObj.text || "";
              const type = itemObj.type || "MAIN";
              let style = "";
              let indent = "0px";
              let bullet = "";

              if (type === "SUM") {
                  style = "font-weight:700; color:#111;";
              } else if (type === "MAIN") {
                  style = "font-weight:600; color:#333;";
                  const hasNum = /^\d+\./.test(txt);
                  if (!hasNum) bullet = "<span style='color:#ccc;margin-right:6px;'>•</span>";
              } else if (type === "SUB") {
                  style = "font-weight:400; color:#555;";
                  indent = "12px";
                  bullet = "<span style='color:#ccc;margin-right:6px;'>-</span>";
              }

              html += `<div style="line-height:20px;${style};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:${indent};">
                  ${bullet}${txt}
              </div>`;
          });
          
          if (hiddenCount > 0) {
              html += `<div style="font-size:11px;color:#9ca3af;margin-top:4px;padding-left:2px;">+ ${hiddenCount} more...</div>`;
          }

          html += `<div style="position:absolute;top:6px;right:8px;font-size:9px;color:#cbd5e1;">#${n.depth}</div>`;

          item.innerHTML = html;
          graphArea.appendChild(item);
          
          pos.set(id, {x, y, h: boxHeight, cy: y + boxHeight / 2});
          y += boxHeight + CONFIG.nodeGap;
      });
      
      svg.setAttribute("height", y + 50);
      svg.setAttribute("width", Math.max(maxContentWidth, CONFIG.panelWidth) + "px");

      graph.edges.forEach(e => {
          const p1 = pos.get(e.from);
          const p2 = pos.get(e.to);
          if (!p1 || !p2) return;
          const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
          
          let d = "";
          let color = "#e5e7eb";
          let width = "1.5";

          if (e.type === "ASK_PAIR" || e.type === "CONTINUE") {
              const lineX = p1.x + 16; 
              const startY = p1.y + p1.h; 
              const endY = p2.y;
              d = `M ${lineX} ${startY} L ${lineX} ${endY}`;
              color = "#d1d5db";
          } else {
              const startX = p1.x + 16; 
              const startY = p1.cy + 12;
              const endX = p2.x - 6; 
              const endY = p2.cy;
              d = `M ${startX} ${startY} L ${startX} ${endY} L ${endX} ${endY}`;
              
              if (e.type==="SELECTION_FOLLOW_UP") { color = "#fbbf24"; width = "2"; } 
          }

          path.setAttribute("d", d);
          path.setAttribute("stroke", color);
          path.setAttribute("stroke-width", width);
          path.setAttribute("fill", "none");
          svg.appendChild(path);
      });
  }

  function exportGraphData() {
      try {
          const nodesArray = Array.from(graph.nodes.values()).map(n => ({
              id: n.id, role: n.role, depth: n.depth, outline: n.outline
          }));
          const exportData = {
              timestamp: new Date().toISOString(),
              nodes: nodesArray, edges: graph.edges, order: graph.order
          };
          const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url; a.download = `gpt_graph_${Date.now()}.json`;
          document.body.appendChild(a); a.click(); document.body.removeChild(a);
      } catch (e) { console.error("Export error", e); }
  }

  header.querySelector('#tm_rebuild').onclick = rebuildGraph;
  header.querySelector('#tm_export').onclick = exportGraphData;

  const mo = new MutationObserver((muts) => {
      let dirty = false;
      muts.forEach(m => {
          m.addedNodes.forEach(n => {
              if(n.nodeType===1 && (n.matches('[data-message-author-role]') || n.querySelector('[data-message-author-role]'))) {
                  dirty = true;
              }
          });
      });
      if(dirty) setTimeout(rebuildGraph, 600);
  });

  let lastUrl = location.href;
  setInterval(() => {
      if (location.href !== lastUrl) {
          lastUrl = location.href;
          setTimeout(rebuildGraph, 1000);
      }
  }, 1000);

  setTimeout(() => {
      mo.observe(document.body, {childList:true, subtree:true});
      rebuildGraph();
  }, 2000);

})();