Add conversation tree visualization with nodes for ChatGPT
// ==UserScript==
// @name ChatGPT Message Info
// @namespace http://tampermonkey.net/
// @version 0.7.0
// @description Add conversation tree visualization with nodes for ChatGPT
// @author MLR
// @match https://chatgpt.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = "[ChatGPT Message Info]:";
// Escape HTML special characters
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// =============================================
// CONFIGURATION & SELECTORS
// =============================================
const CONFIG = {
retryAttempts: 3,
retryDelay: 500,
debounceDelay: 300,
streamingDebounce: 300,
initialDelay: 1000,
apiTimeout: 10000,
topMargin: 200
};
const Settings = {
defaults: {
graph: {
panelMode: 'push', // 'overlay' | 'push'
mergeAssistantChains: false // Merge system/tool/assistant nodes into one
}
},
current: null,
load() {
const saved = GM_getValue('chatgpt-tree-settings', null);
this.current = saved ? JSON.parse(saved) : JSON.parse(JSON.stringify(this.defaults));
return this.current;
},
save() {
GM_setValue('chatgpt-tree-settings', JSON.stringify(this.current));
},
get(module, key) {
if (!this.current) this.load();
return this.current[module]?.[key] ?? this.defaults[module]?.[key];
},
set(module, key, value) {
if (!this.current) this.load();
if (!this.current[module]) this.current[module] = {};
this.current[module][key] = value;
this.save();
}
};
const SELECTORS = {
// ChatGPT specific selectors
chat: {
messageListContainer: 'main > div > div > div',
turnBase: 'div[data-message-author-role]',
mainContainer: 'main'
},
message: {
containers: [
'div[data-message-author-role]',
'[data-testid^="conversation-turn"]'
],
userMessage: '[data-message-author-role="user"]',
assistantMessage: '[data-message-author-role="assistant"]'
}
};
// =============================================
// UTILITY FUNCTIONS
// =============================================
// Universal selector finder
function findElements(selectorArray, context = document) {
for (const selector of selectorArray) {
if (!selector || typeof selector !== 'string' || selector.trim() === '') {
continue;
}
try {
const elements = context.querySelectorAll(selector);
if (elements.length > 0) {
return Array.from(elements);
}
} catch (e) {
console.warn(`${LOG_PREFIX} Invalid selector: ${selector}`, e);
}
}
return [];
}
function findElement(selectorArray, context = document) {
for (const selector of selectorArray) {
if (!selector || typeof selector !== 'string' || selector.trim() === '') {
continue;
}
try {
const element = context.querySelector(selector);
if (element) return element;
} catch (e) {
console.warn(`${LOG_PREFIX} Invalid selector: ${selector}`, e);
}
}
return null;
}
// =============================================
// API FUNCTIONS
// =============================================
// Gets conversation ID from URL
function getConversationId() {
const match = window.location.pathname.match(/\/c\/([^/?]+)/);
return match ? match[1] : null;
}
// Gets session info
async function getSession() {
const response = await fetch("https://chatgpt.com/api/auth/session");
return await response.json();
}
// Retrieves bearer token for API authentication
async function getBearerToken() {
try {
const session = await getSession();
if (!session.accessToken) {
throw new Error('No access token found. Please log in to ChatGPT.');
}
return session.accessToken;
} catch (error) {
console.error(`${LOG_PREFIX} Failed to get bearer token:`, error);
throw new Error(`Failed to get bearer token: ${error.message}`);
}
}
// Fetches conversation data from ChatGPT API
async function fetchConversationData(conversationId) {
try {
console.log(`${LOG_PREFIX} Fetching conversation data for: ${conversationId}`);
const token = await getBearerToken();
const response = await fetch(`https://chatgpt.com/backend-api/conversation/${conversationId}`, {
headers: {
'Accept': '*/*',
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`${LOG_PREFIX} Conversation data received:`, data);
return data;
} catch (error) {
console.error(`${LOG_PREFIX} Failed to fetch conversation:`, error);
return null;
}
}
// Merges system/tool/assistant chains into single nodes
function mergeAssistantChains(nodes, nodeMap, mapping) {
console.log(`${LOG_PREFIX} Starting merge, total nodes: ${nodes.length}`);
const nodesToRemove = [];
const nodesToAdd = [];
const processed = new Set();
// Special case: merge initial system nodes (no request_id at start)
// These are client-created-root and system instructions without a request ID
const rootNode = nodes.find(n => !n.parentUuid);
if (rootNode) {
const initialChain = [];
let current = rootNode;
// Collect nodes while they have no request_id and are not user
while (current && !current.metadata?.request_id && current.role !== 'user') {
initialChain.push(current);
processed.add(current.uuid);
if (current.children.length > 0) {
const nextUuid = current.children[0]; // Only first children!!!!!!!!!
current = nodeMap.get(nextUuid);
} else {
break;
}
}
// If we have multiple nodes, merge them
if (initialChain.length > 1) {
console.log(`${LOG_PREFIX} Found initial chain (no ID): ${initialChain.map(n => `${n.nodeIndex}:${n.role}`).join(' → ')}`);
const lastNode = initialChain[initialChain.length - 1];
const mergedNode = {
uuid: lastNode.uuid,
parentUuid: null,
children: lastNode.children,
role: 'root',
content: initialChain.map(n => {
const c = n.content || '';
return c.trim() ? `${n.nodeIndex}:\n${c}` : '';
}).filter(Boolean).join('\n__\n'),
createTime: lastNode.createTime,
metadata: lastNode.metadata || {},
nodeIndex: 0,
index: 0,
chainNodes: initialChain.map(n => ({
nodeIndex: n.nodeIndex,
uuid: n.uuid,
role: n.role || 'N/A',
content: n.content,
metadata: n.metadata,
parentUuid: n.parentUuid,
children: n.children,
createTime: n.createTime,
rawData: mapping[n.uuid]
}))
};
nodesToRemove.push(...initialChain);
nodesToAdd.push(mergedNode);
}
}
// Find chains by request_id or turn_exchange_id
for (const node of nodes) {
if (processed.has(node.uuid)) continue;
// We do NOT merge user nodes, even if they have a request_id
if (node.role === 'user') continue;
// Start chain only if node has at least one ID
const hasReqId = node.metadata?.request_id;
const hasTurnId = node.metadata?.turn_exchange_id;
if (!hasReqId && !hasTurnId) continue; // Skip nodes without ID (already handled roots above, or dangling nodes)
const chain = [];
let current = node;
// Set of IDs that belong to this chain
const chainIds = new Set();
if (hasReqId) chainIds.add(hasReqId);
if (hasTurnId) chainIds.add(hasTurnId);
// Follow the chain while request_id or turn_exchange_id matches and role is not user
while (current && current.role !== 'user') {
const currReqId = current.metadata?.request_id;
const currTurnId = current.metadata?.turn_exchange_id;
// Check if current node matches any ID in our accumulated set
let matches = false;
if (currReqId && chainIds.has(currReqId)) matches = true;
if (currTurnId && chainIds.has(currTurnId)) matches = true;
// Force match for the very first node
if (chain.length === 0) matches = true;
// NEW: If node has no IDs but chain already started, check if next child has matching ID
// This includes intermediate nodes (like error messages) that don't have turn_exchange_id
if (!matches && chain.length > 0 && !currReqId && !currTurnId) {
if (current.children.length > 0) {
const nextUuid = current.children[0];
const nextNode = nodeMap.get(nextUuid);
if (nextNode) {
const nextReqId = nextNode.metadata?.request_id;
const nextTurnId = nextNode.metadata?.turn_exchange_id;
// If next node matches our chain IDs, include current node
if ((nextReqId && chainIds.has(nextReqId)) || (nextTurnId && chainIds.has(nextTurnId))) {
matches = true;
}
}
}
}
if (matches) {
chain.push(current);
processed.add(current.uuid);
// Add current node's IDs to the set for future children to match
if (currReqId) chainIds.add(currReqId);
if (currTurnId) chainIds.add(currTurnId);
// Get next node (first child)
if (current.children.length > 0) {
const nextUuid = current.children[0];
current = nodeMap.get(nextUuid);
} else {
break;
}
} else {
break;
}
}
// If chain has more than 1 node, merge it
if (chain.length > 1) {
console.log(`${LOG_PREFIX} Found chain (IDs: ${Array.from(chainIds).join(', ')}): ${chain.map(n => `${n.nodeIndex}:${n.role}`).join(' → ')}`);
const firstNode = chain[0];
const lastNode = chain[chain.length - 1];
// Merged nodes need request_id and turn_exchange_id to be grouped with User node during sort
const finalMetadata = { ...lastNode.metadata };
if (!finalMetadata.request_id) {
const nodeWithReqId = chain.find(n => n.metadata?.request_id);
if (nodeWithReqId) finalMetadata.request_id = nodeWithReqId.metadata.request_id;
}
if (!finalMetadata.turn_exchange_id) {
const nodeWithTurnId = chain.find(n => n.metadata?.turn_exchange_id);
if (nodeWithTurnId) finalMetadata.turn_exchange_id = nodeWithTurnId.metadata.turn_exchange_id;
}
// Tool results often have null createTime, causing the merged node to sort to index 0 (top of list).
// We use the first node's time as a fallback, so it gets the correct chronological number.
const finalCreateTime = lastNode.createTime || firstNode.createTime;
const mergedNode = {
uuid: lastNode.uuid,
parentUuid: firstNode.parentUuid,
children: lastNode.children,
role: 'assistant',
content: chain.map(n => {
const c = n.content || '';
return c.trim() ? `${n.nodeIndex}:\n${c}` : '';
}).filter(Boolean).join('\n__\n'),
createTime: finalCreateTime, // Use enriched time
metadata: finalMetadata, // Use enriched metadata
nodeIndex: firstNode.nodeIndex,
index: lastNode.index,
chainNodes: chain.map(n => ({
nodeIndex: n.nodeIndex,
uuid: n.uuid,
role: n.role,
content: n.content,
metadata: n.metadata,
parentUuid: n.parentUuid,
children: n.children,
createTime: n.createTime,
rawData: mapping[n.uuid]
}))
};
nodesToRemove.push(...chain);
nodesToAdd.push(mergedNode);
}
}
console.log(`${LOG_PREFIX} Removing ${nodesToRemove.length} nodes, adding ${nodesToAdd.length} merged nodes`);
// Remove old nodes
for (const node of nodesToRemove) {
const idx = nodes.indexOf(node);
if (idx !== -1) {
nodes.splice(idx, 1);
}
nodeMap.delete(node.uuid);
}
// Add merged nodes
for (const node of nodesToAdd) {
nodes.push(node);
nodeMap.set(node.uuid, node);
if (node.parentUuid) {
const parent = nodeMap.get(node.parentUuid);
if (parent) {
node.parent = parent;
// Update parent's children to point to merged node instead of first chain node
const oldFirstUuid = node.chainNodes[0].uuid;
const childIdx = parent.children.indexOf(oldFirstUuid);
if (childIdx !== -1) {
parent.children[childIdx] = node.uuid;
}
}
}
}
// Find and merge remaining unmerged tool nodes
const unmergedToolNodes = [];
for (const node of nodes) {
if (node.role === 'tool' && !node.chainNodes && node.parentUuid) {
unmergedToolNodes.push(node);
}
}
if (unmergedToolNodes.length > 0) {
console.log(`${LOG_PREFIX} Found ${unmergedToolNodes.length} unmerged tool nodes to merge`);
const nodesToRemove = [];
for (const toolNode of unmergedToolNodes) {
const parentNode = nodeMap.get(toolNode.parentUuid);
if (!parentNode) continue;
// Only merge with assistant/tool parent, skip user parent
if (parentNode.role === 'user') {
continue;
}
// If parent doesn't have chainNodes, create them
if (!parentNode.chainNodes) {
parentNode.chainNodes = [{
nodeIndex: parentNode.nodeIndex,
uuid: parentNode.uuid,
role: parentNode.role,
content: parentNode.content,
metadata: parentNode.metadata,
parentUuid: parentNode.parentUuid,
children: parentNode.children,
createTime: parentNode.createTime,
rawData: mapping[parentNode.uuid]
}];
}
// Add tool node to chainNodes
parentNode.chainNodes.push({
nodeIndex: toolNode.nodeIndex,
uuid: toolNode.uuid,
role: toolNode.role,
content: toolNode.content,
metadata: toolNode.metadata,
parentUuid: toolNode.parentUuid,
children: toolNode.children,
createTime: toolNode.createTime,
rawData: mapping[toolNode.uuid]
});
// Transfer children
for (const childUuid of toolNode.children) {
if (!parentNode.children.includes(childUuid)) {
parentNode.children.push(childUuid);
const childNode = nodeMap.get(childUuid);
if (childNode) {
childNode.parentUuid = parentNode.uuid;
childNode.parent = parentNode;
}
}
}
// Remove tool UUID from parent's children array
const toolIdx = parentNode.children.indexOf(toolNode.uuid);
if (toolIdx !== -1) {
parentNode.children.splice(toolIdx, 1);
}
// Mark for removal
nodesToRemove.push(toolNode);
}
// Rebuild content for parent nodes that got new chainNodes
const modifiedParents = new Set(unmergedToolNodes.map(n => n.parentUuid));
for (const parentUuid of modifiedParents) {
const parentNode = nodeMap.get(parentUuid);
if (parentNode && parentNode.chainNodes) {
parentNode.content = parentNode.chainNodes.map(n => {
const c = n.content || '';
return c.trim() ? `${n.nodeIndex}:\n${c}` : '';
}).filter(Boolean).join('\n__\n');
}
}
// Remove all marked nodes
for (const toolNode of nodesToRemove) {
const idx = nodes.indexOf(toolNode);
if (idx !== -1) {
nodes.splice(idx, 1);
}
}
console.log(`${LOG_PREFIX} Merged and removed ${nodesToRemove.length} tool nodes`);
}
// Recalculate nodeIndex by createTime (chronological order)
// Sort all nodes by createTime
const sortedNodes = [...nodes].sort((a, b) => {
// For nodes without createTime, use parent's time for sorting only
let timeA = a.createTime;
let timeB = b.createTime;
if (!timeA && a.parentUuid) {
const parentA = nodeMap.get(a.parentUuid);
timeA = parentA?.createTime || 0;
} else {
timeA = timeA || 0;
}
if (!timeB && b.parentUuid) {
const parentB = nodeMap.get(b.parentUuid);
timeB = parentB?.createTime || 0;
} else {
timeB = timeB || 0;
}
const reqA = a.metadata?.request_id;
const reqB = b.metadata?.request_id;
const turnA = a.metadata?.turn_exchange_id;
const turnB = b.metadata?.turn_exchange_id;
// 1. User + Assistant pair grouping
// If nodes share the same request_id or turn_exchange_id (User + GPT pair)
// force User to come before Assistant, ignoring timestamps.
// Handle ChatGPT API quirk: User message create_time can be later than the Assistant's response.
// We check turn_exchange_id because merged nodes might only carry this ID.
const isSameGroup = (reqA && reqA === reqB) || (turnA && turnA === turnB);
if (isSameGroup) {
if (a.role === 'user' && b.role !== 'user') return -1; // User first
if (b.role === 'user' && a.role !== 'user') return 1; // User first
}
// 2. Siblings (same parent) order
// Ensure v1 comes before v2, v2 before v3, etc.
// This fixes cases where v2 has an earlier timestamp than v1 due to API quirks.
if (a.parent && a.parent === b.parent) {
const siblings = a.parent.children; // Array of UUIDs
const indexA = siblings.indexOf(a.uuid);
const indexB = siblings.indexOf(b.uuid);
// If both are found in parent's children array, sort by that index
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB; // Sort by array index (0, 1, 2...)
}
}
// 3. Default: Sort by time
return timeA - timeB;
});
// Assign indices chronologically
sortedNodes.forEach((node, index) => {
// Save old nodeIndex as unmerged for non-chainNodes (User nodes)
if (!node.chainNodes) {
node.unmergedIndex = node.nodeIndex;
}
node.nodeIndex = index;
// FIX: Sync 'index' with 'nodeIndex'.
// 'index' is used later by buildTreeStructure to sort children for branch assignment.
// By setting it to the chronological order, we ensure Branch 1 comes before Branch 2.
node.index = index;
});
console.log(`${LOG_PREFIX} Merge complete, final nodes: ${nodes.length}`);
}
// Builds tree structure from conversation mapping with branch detection
function buildTreeStructure(conversationData) {
if (!conversationData || !conversationData.mapping) {
console.error(`${LOG_PREFIX} Invalid conversation data`);
return null;
}
const mapping = conversationData.mapping;
const nodes = [];
const nodeMap = new Map();
const messageToBranch = new Map();
// First pass: create all nodes
for (const [uuid, data] of Object.entries(mapping)) {
const message = data.message;
const role = message?.author?.role || 'N/A';
const content = extractMessageContent(message);
const node = {
uuid: uuid,
parentUuid: data.parent || null,
children: data.children || [],
role: role,
content: content,
createTime: message?.create_time || null,
metadata: message?.metadata || {},
nodeIndex: nodes.length, // Add index for displaying numbers in node
index: message?.create_time || 0 // Use create_time as index for sorting
};
nodes.push(node);
nodeMap.set(uuid, node);
}
// Second pass: establish parent-child relationships
for (const node of nodes) {
if (node.parentUuid && nodeMap.has(node.parentUuid)) {
node.parent = nodeMap.get(node.parentUuid);
}
}
// Third pass: restructure User→User chains after GPT responses
// If User node has both GPT child and User child, User child should be child of GPT
for (const node of nodes) {
if (node.role !== 'user' || node.children.length < 2) continue;
const children = node.children.map(uuid => nodeMap.get(uuid)).filter(Boolean);
// Find GPT child with same IDs (pair)
const gptPairChild = children.find(child => {
if (child.role === 'user') return false;
const parentReqId = node.metadata?.request_id;
const parentTurnId = node.metadata?.turn_exchange_id;
const childReqId = child.metadata?.request_id;
const childTurnId = child.metadata?.turn_exchange_id;
return (parentReqId && parentReqId === childReqId) ||
(parentTurnId && parentTurnId === childTurnId);
});
// Find User child with different IDs (next message)
const userNextChild = children.find(child => {
if (child.role !== 'user') return false;
const parentReqId = node.metadata?.request_id;
const parentTurnId = node.metadata?.turn_exchange_id;
const childReqId = child.metadata?.request_id;
const childTurnId = child.metadata?.turn_exchange_id;
const sameIds = (parentReqId && parentReqId === childReqId) ||
(parentTurnId && parentTurnId === childTurnId);
return !sameIds;
});
// Reconnect: User next should be child of GPT pair
if (gptPairChild && userNextChild) {
// Remove userNext from current node's children
node.children = node.children.filter(uuid => uuid !== userNextChild.uuid);
// Add userNext as child of GPT
if (!gptPairChild.children.includes(userNextChild.uuid)) {
gptPairChild.children.push(userNextChild.uuid);
}
// Update userNext's parent
userNextChild.parentUuid = gptPairChild.uuid;
userNextChild.parent = gptPairChild;
}
}
// Merge assistant chains if setting enabled
const mergeEnabled = Settings.get('graph', 'mergeAssistantChains');
if (mergeEnabled) {
mergeAssistantChains(nodes, nodeMap, mapping);
} else {
// Even without merging, apply the same chronological sorting to ensure correct branch numbering based on time, not DFS order
const sortedNodes = [...nodes].sort((a, b) => {
const timeA = a.createTime || 0;
const timeB = b.createTime || 0;
const reqA = a.metadata?.request_id;
const reqB = b.metadata?.request_id;
const turnA = a.metadata?.turn_exchange_id;
const turnB = b.metadata?.turn_exchange_id;
// 1. User + Assistant pair grouping
// If nodes share the same request_id or turn_exchange_id (User + GPT pair)
// force User to come before Assistant, ignoring timestamps.
// Handle ChatGPT API quirk: User message create_time can be later than the Assistant's response.
// We check turn_exchange_id because merged nodes might only carry this ID.
const isSameGroup = (reqA && reqA === reqB) || (turnA && turnA === turnB);
if (isSameGroup) {
if (a.role === 'user' && b.role !== 'user') return -1; // User first
if (b.role === 'user' && a.role !== 'user') return 1; // User first
}
// 2. Siblings (same parent) order
// Ensure v1 comes before v2, v2 before v3, etc.
// This fixes cases where v2 has an earlier timestamp than v1 due to API quirks.
if (a.parent && a.parent === b.parent) {
const siblings = a.parent.children; // Array of UUIDs
const indexA = siblings.indexOf(a.uuid);
const indexB = siblings.indexOf(b.uuid);
// If both are found in parent's children array, sort by that index
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB; // Sort by array index (0, 1, 2...)
}
}
// 3. Default: Sort by time
return timeA - timeB;
});
// Assign indices chronologically
sortedNodes.forEach((node, index) => {
// DON'T change nodeIndex in non-merged mode - keep API order!
// Only change 'index' for correct branch numbering by time
node.index = index;
});
}
// Find root node and main branch path
const rootNode = nodes.find(n => !n.parentUuid);
// Trace main branch from current_node back to root
const mainBranchUuids = new Set();
let currentUuid = conversationData.current_node;
while (currentUuid) {
mainBranchUuids.add(currentUuid);
const node = nodeMap.get(currentUuid);
currentUuid = node?.parentUuid;
}
// Find all branch points (nodes with multiple children)
const branchStartPoints = [];
for (const node of nodes) {
if (node.children.length > 1) {
// Sort children by index
const childNodes = node.children
.map(uuid => nodeMap.get(uuid))
.filter(Boolean)
.sort((a, b) => a.index - b.index);
// All children except first are new branch starts
for (let i = 1; i < childNodes.length; i++) {
const child = childNodes[i];
const parentReqId = node.metadata?.request_id;
const parentTurnId = node.metadata?.turn_exchange_id;
const childReqId = child.metadata?.request_id;
const childTurnId = child.metadata?.turn_exchange_id;
const isSameGroup =
(parentReqId && parentReqId === childReqId) ||
(parentTurnId && parentTurnId === childTurnId);
if (isSameGroup || (node.role === 'user' && child.role === 'user')) {
continue;
}
branchStartPoints.push({
node: child,
index: child.index
});
}
}
}
// Sort branch start points by index
branchStartPoints.sort((a, b) => a.index - b.index);
// Assign branch numbers to start points
const nodeToBranchNumber = new Map();
branchStartPoints.forEach((p, i) => {
nodeToBranchNumber.set(p.node.uuid, i + 2); // Start from 2
});
// Recursively assign branches
function assignBranch(nodeUuid, currentBranch) {
const node = nodeMap.get(nodeUuid);
if (!node) return;
const isMainBranch = mainBranchUuids.has(node.uuid);
messageToBranch.set(node.uuid, {
branchIndex: currentBranch,
isMainBranch
});
if (node.children.length === 0) return;
// Sort children by index
const childNodes = node.children
.map(uuid => nodeMap.get(uuid))
.filter(Boolean)
.sort((a, b) => a.index - b.index);
// First child continues current branch
assignBranch(childNodes[0].uuid, currentBranch);
// Other children start new branches
for (let i = 1; i < childNodes.length; i++) {
const child = childNodes[i];
const newBranch = nodeToBranchNumber.get(child.uuid) || currentBranch;
assignBranch(child.uuid, newBranch);
}
}
// Start assignment from root with branch 1
if (rootNode) {
assignBranch(rootNode.uuid, 1);
}
console.log(`${LOG_PREFIX} Built tree with ${nodes.length} nodes, ${branchStartPoints.length} branch points`);
console.log(`${LOG_PREFIX} Main branch has ${mainBranchUuids.size} nodes`);
return {
nodes: nodes,
nodeMap: nodeMap,
rootNode: rootNode,
currentLeaf: conversationData.current_node,
messageToBranch: messageToBranch,
mainBranchUuids: mainBranchUuids,
activePath: mainBranchUuids, // activePath = mainBranchUuids (same thing)
rawMapping: mapping // Save original mapping for Raw JSON
};
}
// Extracts text content from message
function extractMessageContent(message) {
if (!message || !message.content) return '';
const content = message.content;
let extractedText = '';
// Handle text property (used for 'code' content_type)
if (content.text && typeof content.text === 'string') {
extractedText = content.text;
}
// Handle parts array (standard text/multimodal)
else if (content.parts && Array.isArray(content.parts)) {
// Extract text parts
extractedText = content.parts
.filter(p => typeof p === 'string')
.join('\n')
.trim();
}
// Handle direct string content
else if (typeof content === 'string') {
extractedText = content;
}
// Add image_gen_title from metadata if exists
if (message.metadata && message.metadata.image_gen_title) {
const title = message.metadata.image_gen_title;
extractedText = extractedText ? `${extractedText}\n\n[Image title: ${title}]` : `[Image title: ${title}]`;
}
return extractedText;
}
// =============================================
// TREE VISUALIZATION
// =============================================
class ConversationTreePanel {
constructor() {
this.isOpen = false;
this.treeData = null;
this.canvas = null;
this.ctx = null;
this.container = null;
this.currentHighlightedNode = null;
this.canvasOffset = { x: 0, y: 0 };
this.canvasScale = 1;
this.isDragging = false;
this.dragStart = { x: 0, y: 0 };
this.nodePositions = new Map();
this.isResizing = false;
this.panelWidth = 400;
Settings.load();
}
async initialize() {
console.log(`${LOG_PREFIX} Initializing tree panel`);
this.createPanelStructure();
this.createToggleButton();
await this.loadConversationTree();
}
createPanelStructure() {
// Create wrapper
const wrapper = document.createElement('div');
wrapper.id = 'chatgpt-tree-panel-wrapper';
wrapper.className = 'tree-panel-wrapper';
wrapper.style.display = 'none';
// Create panel
const panel = document.createElement('div');
panel.className = 'tree-panel';
// Create header
const header = document.createElement('div');
header.className = 'tree-panel-header';
header.innerHTML = `
<div class="tree-header-content">
<h3>Conversation Tree</h3>
<div class="tree-header-controls">
<button class="tree-control-btn" id="tree-settings-btn" title="Settings">⚙️</button>
<button class="tree-control-btn" id="tree-reset-btn" title="Reset zoom">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
</button>
<button class="tree-control-btn" id="tree-refresh-btn" title="Refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
</button>
<button class="tree-control-btn" id="tree-close-btn" title="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
// Create content container
const content = document.createElement('div');
content.className = 'tree-panel-content';
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.className = 'tree-canvas';
this.canvas.style.cssText = 'display: block; cursor: grab;';
content.appendChild(this.canvas);
panel.appendChild(header);
panel.appendChild(content);
// Resizer handle
const resizer = document.createElement('div');
resizer.className = 'tree-resizer-group';
resizer.innerHTML = `
<div class="tree-resizer-line"></div>
<div class="tree-resizer-handle"></div>
`;
resizer.addEventListener('mousedown', (e) => this.startResizing(e));
wrapper.appendChild(resizer);
wrapper.appendChild(panel);
document.body.appendChild(wrapper);
this.container = wrapper;
// Setup canvas event handlers
this.setupCanvasHandlers();
// Add event listeners
document.getElementById('tree-close-btn').addEventListener('click', () => this.toggle());
document.getElementById('tree-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('tree-settings-btn').addEventListener('click', () => this.openSettings());
document.getElementById('tree-reset-btn').addEventListener('click', () => this.resetZoom());
}
resetZoom() {
console.log(`${LOG_PREFIX} Resetting zoom`);
this.canvasOffset = { x: 0, y: 0 };
this.canvasScale = 1;
if (this.treeData) {
this.renderTree();
}
}
setupCanvasHandlers() {
// Click handler for node selection
this.canvas.addEventListener('click', (e) => {
if (this.isDragging) return;
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - this.canvasOffset.x) / this.canvasScale;
const y = (e.clientY - rect.top - this.canvasOffset.y) / this.canvasScale;
const node = this.findNodeAtPosition(x, y);
if (node) {
this.onNodeClick(node);
}
});
// Mouse move for dragging and hover
this.canvas.addEventListener('mousemove', (e) => {
if (this.isDragging) {
const dx = e.clientX - this.dragStart.x;
const dy = e.clientY - this.dragStart.y;
this.canvasOffset.x += dx;
this.canvasOffset.y += dy;
this.dragStart = { x: e.clientX, y: e.clientY };
this.renderTree();
return;
}
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - this.canvasOffset.x) / this.canvasScale;
const y = (e.clientY - rect.top - this.canvasOffset.y) / this.canvasScale;
const node = this.findNodeAtPosition(x, y);
if (node) {
this.canvas.style.cursor = 'pointer';
} else {
this.canvas.style.cursor = this.isDragging ? 'grabbing' : 'grab';
}
});
// Mouse down - start dragging
this.canvas.addEventListener('mousedown', (e) => {
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - this.canvasOffset.x) / this.canvasScale;
const y = (e.clientY - rect.top - this.canvasOffset.y) / this.canvasScale;
const node = this.findNodeAtPosition(x, y);
if (!node) {
this.isDragging = true;
this.dragStart = { x: e.clientX, y: e.clientY };
this.canvas.style.cursor = 'grabbing';
}
});
// Mouse up - stop dragging
this.canvas.addEventListener('mouseup', () => {
this.isDragging = false;
this.canvas.style.cursor = 'grab';
});
// Mouse leave - stop dragging
this.canvas.addEventListener('mouseleave', () => {
this.isDragging = false;
this.canvas.style.cursor = 'grab';
});
// Zoom with mouse wheel
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.3, Math.min(3, this.canvasScale * zoomDelta));
// Zoom to cursor position
const scaleDiff = newScale - this.canvasScale;
this.canvasOffset.x -= (mouseX - this.canvasOffset.x) * (scaleDiff / this.canvasScale);
this.canvasOffset.y -= (mouseY - this.canvasOffset.y) * (scaleDiff / this.canvasScale);
this.canvasScale = newScale;
this.renderTree();
});
}
createToggleButton() {
const button = document.createElement('button');
button.id = 'chatgpt-tree-toggle-btn';
button.className = 'tree-toggle-btn';
button.title = 'Show Conversation Tree';
button.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="5" r="2"></circle>
<circle cx="6" cy="12" r="2"></circle>
<circle cx="18" cy="12" r="2"></circle>
<circle cx="12" cy="19" r="2"></circle>
<line x1="12" y1="7" x2="12" y2="17"></line>
<line x1="12" y1="7" x2="6" y2="10"></line>
<line x1="12" y1="7" x2="18" y2="10"></line>
</svg>
`;
button.addEventListener('click', () => this.toggle());
document.body.appendChild(button);
}
async loadConversationTree() {
const conversationId = getConversationId();
if (!conversationId) {
console.warn(`${LOG_PREFIX} No conversation ID found`);
this.showError('No conversation found');
return;
}
const data = await fetchConversationData(conversationId);
if (!data) {
console.error(`${LOG_PREFIX} Failed to load conversation data`);
this.showError('Failed to load conversation');
return;
}
this.treeData = buildTreeStructure(data);
if (this.treeData) {
this.renderTree();
} else {
this.showError('Failed to build tree structure');
}
}
showError(message) {
if (!this.canvas) {
console.error(`${LOG_PREFIX} No canvas for error display`);
return;
}
const content = this.container.querySelector('.tree-panel-content');
const rect = content.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// Set canvas size
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(dpr, dpr);
// Clear and draw error
this.ctx.fillStyle = '#0f172a';
this.ctx.fillRect(0, 0, width, height);
this.ctx.fillStyle = '#ef4444';
this.ctx.font = '14px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText(message, width / 2, height / 2);
}
renderTree() {
if (!this.treeData || !this.canvas) {
console.error(`${LOG_PREFIX} No tree data or canvas to render`);
return;
}
const content = this.container.querySelector('.tree-panel-content');
const rect = content.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// Set canvas size with device pixel ratio
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(dpr, dpr);
// Clear canvas
this.ctx.fillStyle = '#0f172a';
this.ctx.fillRect(0, 0, width, height);
// Apply transformation
this.ctx.save();
this.ctx.translate(this.canvasOffset.x, this.canvasOffset.y);
this.ctx.scale(this.canvasScale, this.canvasScale);
// Calculate layout
this.calculateLayout();
// Draw edges first
this.drawEdges();
// Draw nodes
this.drawNodes();
this.ctx.restore();
console.log(`${LOG_PREFIX} Tree rendered with ${this.treeData.nodes.length} nodes`);
}
calculateLayout() {
const { nodes, rootNode } = this.treeData;
const nodeSize = 80; // Increased for branches
const levelHeight = 120;
this.nodePositions.clear();
// Recursively build tree with branches
const buildLayout = (node, level, xOffset) => {
if (!node) return { width: 0, minX: 0, maxX: 0 };
// Position of current node
const y = level * levelHeight + 50;
// Get children
const children = node.children
.map(childId => this.treeData.nodeMap.get(childId))
.filter(Boolean);
if (children.length === 0) {
// Leaf node
const x = xOffset;
this.nodePositions.set(node.uuid, {
x,
y,
node,
radius: 20
});
return { width: nodeSize, minX: x, maxX: x };
}
// Recursively place children
let currentX = xOffset;
let minX = Infinity;
let maxX = -Infinity;
children.forEach(child => {
const result = buildLayout(child, level + 1, currentX);
minX = Math.min(minX, result.minX);
maxX = Math.max(maxX, result.maxX);
currentX += result.width;
});
// Place current node
// Find group of closest children (gap no more than 450px between neighbors)
const childrenPositions = [];
children.forEach(child => {
const childPos = this.nodePositions.get(child.uuid);
if (childPos) {
childrenPositions.push(childPos.x);
}
});
childrenPositions.sort((a, b) => a - b);
// Find largest group of close children
let bestGroupStart = 0;
let bestGroupEnd = 0;
let currentGroupStart = 0;
for (let i = 1; i < childrenPositions.length; i++) {
const gap = childrenPositions[i] - childrenPositions[i - 1];
if (gap > 450) {
// Large gap - start new group
const currentGroupSize = i - currentGroupStart;
const bestGroupSize = bestGroupEnd - bestGroupStart;
if (currentGroupSize > bestGroupSize) {
bestGroupStart = currentGroupStart;
bestGroupEnd = i;
}
currentGroupStart = i;
}
}
// Check last group
const lastGroupSize = childrenPositions.length - currentGroupStart;
const bestGroupSize = bestGroupEnd - bestGroupStart;
if (lastGroupSize > bestGroupSize) {
bestGroupStart = currentGroupStart;
bestGroupEnd = childrenPositions.length;
}
// Center between children from best group
const groupMinX = childrenPositions[bestGroupStart];
const groupMaxX = childrenPositions[bestGroupEnd - 1];
const x = (groupMinX + groupMaxX) / 2;
this.nodePositions.set(node.uuid, {
x,
y,
node,
radius: 20
});
return { width: maxX - minX + nodeSize, minX, maxX };
};
buildLayout(rootNode, 0, 0);
console.log(`${LOG_PREFIX} Layout calculated: ${this.nodePositions.size} positions`);
}
drawEdges() {
const { nodes } = this.treeData;
nodes.forEach(node => {
if (!node.parentUuid) return;
const nodePos = this.nodePositions.get(node.uuid);
const parentPos = this.nodePositions.get(node.parentUuid);
if (!nodePos || !parentPos) return;
const parentNode = this.treeData.nodeMap.get(node.parentUuid);
if (!parentNode) return;
const branchInfo = this.treeData.messageToBranch.get(node.uuid);
const isMainBranch = branchInfo?.isMainBranch || false;
// Determine same request_id chain
const parentRequestId = parentNode.metadata?.request_id;
const childRequestId = node.metadata?.request_id;
const parentTurnId = parentNode.metadata?.turn_exchange_id;
const childTurnId = node.metadata?.turn_exchange_id;
const childMessageType = node.metadata?.message_type;
const isSameRequestChain =
(parentNode.role === 'user' && node.role !== 'user') ||
(childMessageType === "next" || childMessageType === "variant") ||
(parentRequestId && parentRequestId === childRequestId) ||
(parentTurnId && parentTurnId === childTurnId);
// Choose colors and line style
let strokeStyle;
let lineWidth;
let lineDash = [];
if (isSameRequestChain) {
// Dashed line yellow-blue or yellow-gray
lineWidth = isMainBranch ? 3 : 2;
lineDash = [8, 4]; // Dash pattern
// Draw double line: first yellow, then blue/gray
// Yellow line (base)
this.ctx.beginPath();
this.ctx.strokeStyle = '#fbbf24'; // Yellow
this.ctx.lineWidth = lineWidth;
this.ctx.setLineDash(lineDash);
this.ctx.moveTo(parentPos.x, parentPos.y);
const controlPointY = (parentPos.y + nodePos.y) / 2;
this.ctx.bezierCurveTo(
parentPos.x, controlPointY,
nodePos.x, controlPointY,
nodePos.x, nodePos.y
);
this.ctx.stroke();
// Blue/gray line (on top, with offset)
this.ctx.beginPath();
this.ctx.strokeStyle = isMainBranch ? '#3b82f6' : '#475569';
this.ctx.lineWidth = lineWidth - 1;
this.ctx.lineDashOffset = 6; // Offset for alternating effect
this.ctx.moveTo(parentPos.x, parentPos.y);
this.ctx.bezierCurveTo(
parentPos.x, controlPointY,
nodePos.x, controlPointY,
nodePos.x, nodePos.y
);
this.ctx.stroke();
// Reset dash pattern
this.ctx.setLineDash([]);
this.ctx.lineDashOffset = 0;
} else {
// Regular solid line
strokeStyle = isMainBranch ? '#3b82f6' : '#475569';
lineWidth = isMainBranch ? 3 : 2;
this.ctx.beginPath();
this.ctx.strokeStyle = strokeStyle;
this.ctx.lineWidth = lineWidth;
this.ctx.moveTo(parentPos.x, parentPos.y);
const controlPointY = (parentPos.y + nodePos.y) / 2;
this.ctx.bezierCurveTo(
parentPos.x, controlPointY,
nodePos.x, controlPointY,
nodePos.x, nodePos.y
);
this.ctx.stroke();
}
});
}
drawNodes() {
this.nodePositions.forEach((pos, uuid) => {
const node = pos.node;
const branchInfo = this.treeData.messageToBranch.get(uuid);
const isMainBranch = branchInfo?.isMainBranch || false;
const isInActivePath = this.treeData.activePath.has(uuid);
const isSelected = uuid === this.currentHighlightedNode;
// Draw circle
this.ctx.beginPath();
this.ctx.arc(pos.x, pos.y, pos.radius, 0, Math.PI * 2);
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fill();
// Border
this.ctx.lineWidth = isSelected ? 4 : (isInActivePath ? 3 : 2);
if (isSelected) {
// Purple shimmer style
const time = Date.now() / 1000;
const pulse = Math.sin(time * 3) * 0.3 + 0.7; // Pulse from 0.4 to 1.0
// Create gradient for shimmer effect
const gradient = this.ctx.createRadialGradient(
pos.x, pos.y, pos.radius * 0.5,
pos.x, pos.y, pos.radius * 2
);
// Purple tones with pulse
gradient.addColorStop(0, `rgba(168, 85, 247, ${pulse})`); // Bright purple center
gradient.addColorStop(0.5, `rgba(139, 92, 246, ${pulse * 0.8})`);
gradient.addColorStop(1, `rgba(109, 40, 217, 0)`); // Radial fade to transparent
// Glow effect
this.ctx.shadowColor = '#a855f7'; // Purple shadow color
this.ctx.shadowBlur = 15 + 5 * Math.sin(time * 5); // Breathing glow
this.ctx.strokeStyle = '#c084fc'; // Bright purple border
this.ctx.stroke();
// Add outer light effect
this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(pos.x, pos.y, pos.radius, 0, Math.PI * 2);
this.ctx.strokeStyle = gradient;
this.ctx.lineWidth = 6; // Thicker for glow effect
this.ctx.stroke();
this.ctx.restore();
this.ctx.shadowBlur = 0; // Reset shadow
} else if (isInActivePath) {
this.ctx.strokeStyle = '#fbbf24'; // Yellow for main branch
this.ctx.stroke();
if (isMainBranch) {
this.ctx.shadowColor = '#fbbf24';
this.ctx.shadowBlur = 4;
this.ctx.stroke();
this.ctx.shadowBlur = 0;
}
} else {
// === REGULAR WHITE STYLE ===
this.ctx.strokeStyle = '#ffffff';
this.ctx.stroke();
}
// Draw nodeIndex number inside the circle
this.ctx.fillStyle = '#ffffff';
this.ctx.font = 'bold 11px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(node.nodeIndex.toString(), pos.x, pos.y);
// Content indicator (top-right corner)
const hasContent = node.content && node.content.trim().length > 0;
const contentColor = hasContent ? '#10b981' : '#64748b'; // Green if exists, else grey
this.ctx.fillStyle = contentColor;
this.ctx.beginPath();
this.ctx.arc(pos.x + 17, pos.y - 17, 5, 0, Math.PI * 2);
this.ctx.fill();
// Image indicator (top-left corner)
const nodeHasImage = this.hasImage(node);
if (nodeHasImage) {
this.ctx.fillStyle = '#14b8a6'; // Teal color like in screenshot
this.ctx.beginPath();
this.ctx.arc(pos.x - 17, pos.y - 17, 6, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(pos.x - 17 - 3, pos.y - 17 - 3, 6, 5);
this.ctx.fillStyle = '#14b8a6';
this.ctx.beginPath();
this.ctx.arc(pos.x - 17 + 1, pos.y - 17 - 1, 1.5, 0, Math.PI * 2);
this.ctx.fill();
}
// Draw label
this.ctx.fillStyle = '#e2e8f0';
this.ctx.font = isMainBranch ? 'bold 11px sans-serif' : '11px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'alphabetic'; // Reset baseline
this.ctx.fillText(this.getNodeLabel(node), pos.x, pos.y + pos.radius + 15);
// Get version info (sibling index)
const parent = node.parent;
let versionIndex = 1;
let totalVersions = 1;
if (parent && parent.children.length > 1) {
const sameRoleSiblings = parent.children
.map(uuid => this.treeData.nodeMap.get(uuid))
.filter(s => s && s.role === node.role);
if (sameRoleSiblings.length > 1 &&
!(parent.role === 'user' && node.role === 'user')) {
versionIndex = sameRoleSiblings.findIndex(s => s.uuid === node.uuid) + 1;
totalVersions = sameRoleSiblings.length;
}
}
// Show Version and Branch info
const branchNumber = branchInfo ? branchInfo.branchIndex : 1;
this.ctx.fillStyle = '#94a3b8';
this.ctx.font = '9px sans-serif';
// Version (if siblings exist)
if (totalVersions > 1) {
this.ctx.fillText(`v${versionIndex}/${totalVersions}`, pos.x, pos.y + pos.radius + 28);
}
// Branch number
this.ctx.fillText(`b${branchNumber}`, pos.x, pos.y + pos.radius + (totalVersions > 1 ? 40 : 28));
});
}
findNodeAtPosition(x, y) {
for (const [uuid, pos] of this.nodePositions) {
const dx = x - pos.x;
const dy = y - pos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= pos.radius) {
return pos.node;
}
}
return null;
}
getNodeColor(node) {
// Check for stop reason - red if interrupted
const finishDetails = node.metadata?.finish_details;
const stopReason = finishDetails?.reason || finishDetails?.type;
if (stopReason && (stopReason === 'client_stopped' || stopReason === 'interrupted' || stopReason === 'user_canceled')) {
return '#ef4444'; // Red
}
const colors = {
'user': '#3b82f6', // Blue
'assistant': '#10b981', // Green
'system': '#6366f1', // Indigo
'tool': '#f59e0b', // Amber
'root': '#64748b' // Gray
};
return colors[node.role] || '#64748b';
}
getNodeLabel(node) {
if (node.role === 'user') return 'User';
if (node.role === 'assistant') return 'GPT';
if (node.role === 'root') return 'Root';
return node.role;
}
hasImage(node) {
// Check if node has image in chainNodes
if (node.chainNodes) {
return node.chainNodes.some(n => this.hasImageInContent(n));
}
// Check if node itself has image
return this.hasImageInContent(node);
}
hasImageInContent(node) {
if (!node.rawData || !node.rawData.message || !node.rawData.message.content) {
return false;
}
const content = node.rawData.message.content;
if (content.content_type === 'multimodal_text' && Array.isArray(content.parts)) {
return content.parts.some(part =>
part.content_type === 'image_asset_pointer' && part.asset_pointer
);
}
return false;
}
highlightNode(uuid) {
this.currentHighlightedNode = uuid;
this.renderTree();
}
onNodeClick(node) {
console.log(`${LOG_PREFIX} Node clicked:`, node);
this.highlightNode(node.uuid);
this.showNodeDetails(node);
}
showNodeDetails(node) {
const content = node.content || '';
const timeStr = node.createTime ? new Date(node.createTime * 1000).toLocaleString() : 'N/A';
const preview = content.substring(0, 100);
const hasMore = content.length > 100;
// Get branch info
const branchInfo = this.treeData.messageToBranch.get(node.uuid);
const branchNumber = branchInfo ? branchInfo.branchIndex : 1; // Branch number
const branchStatus = branchInfo?.isMainBranch ? 'Main' : 'Side';
// Get version info (sibling index)
const parent = node.parent;
let versionIndex = 1;
let totalVersions = 1;
if (parent && parent.children.length > 1) {
const sameRoleSiblings = parent.children
.map(uuid => this.treeData.nodeMap.get(uuid))
.filter(s => s && s.role === node.role);
if (sameRoleSiblings.length > 1 &&
!(parent.role === 'user' && node.role === 'user')) {
versionIndex = sameRoleSiblings.findIndex(s => s.uuid === node.uuid) + 1;
totalVersions = sameRoleSiblings.length;
}
}
// Get stop reason from metadata
const finishDetails = node.metadata?.finish_details;
const stopReason = finishDetails?.reason || finishDetails?.type || null;
// Get model from metadata
const modelSlug = node.metadata?.model_slug || null;
// Get raw JSON from original mapping
const rawNodeData = this.treeData.rawMapping ? this.treeData.rawMapping[node.uuid] : null;
let rawJSON = rawNodeData ? JSON.stringify(rawNodeData, null, 2) : 'N/A';
// Build displays for merged nodes
let uuidDisplay = node.uuid;
let parentDisplay = node.parentUuid || 'ROOT';
let roleDisplay = node.role;
let createdDisplay = timeStr;
let childrenDisplay = node.children.length.toString();
let unmergedDisplay = '';
const mergeEnabled = Settings.get('graph', 'mergeAssistantChains');
if (node.chainNodes && node.chainNodes.length > 0) {
// Merged node - show all data with nodeIndex
uuidDisplay = node.chainNodes.map(n => `${n.nodeIndex}: ${n.uuid}`).join('<br>');
parentDisplay = node.chainNodes.map(n => `${n.nodeIndex}: ${n.parentUuid || 'ROOT'}`).join('<br>');
roleDisplay = node.chainNodes.map(n => `${n.nodeIndex}: ${n.role}`).join('<br>');
createdDisplay = node.chainNodes.map(n => {
const t = n.createTime ? `${new Date(n.createTime * 1000).toLocaleString()} (${n.createTime})` : 'N/A';
return `${n.nodeIndex}: ${t}`;
}).join('<br>');
childrenDisplay = node.chainNodes.map(n => `${n.nodeIndex}: ${n.children.length}`).join('<br>');
rawJSON = node.chainNodes.map(n => JSON.stringify(n.rawData, null, 2)).join('\n');
// Unmerged display
if (mergeEnabled) {
unmergedDisplay = node.chainNodes.map(n => n.nodeIndex).join(',');
}
} else if (mergeEnabled && node.unmergedIndex !== undefined) {
// Merged mode but NOT chainNode (e.g. User)
unmergedDisplay = node.unmergedIndex.toString();
}
// Create modal
const modal = document.createElement('div');
modal.className = 'cpm-graph-modal-overlay';
modal.innerHTML = `
<div class="cpm-graph-modal">
<div class="cpm-graph-modal-header">
<h3>${node.nodeIndex.toString()} — ${this.getNodeLabel(node)}</h3>
<button class="cpm-graph-modal-close">×</button>
</div>
<div class="cpm-graph-modal-body">
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">UUID:</span>
<span class="cpm-graph-value">${uuidDisplay}</span>
</div>
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Parent UUID:</span>
<span class="cpm-graph-value">${node.chainNodes ? parentDisplay : (node.parentUuid || 'ROOT')}</span>
</div>
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Role:</span>
<span class="cpm-graph-value">${node.chainNodes ? roleDisplay : node.role}</span>
</div>
${modelSlug ? `
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Model:</span>
<span class="cpm-graph-value">${modelSlug}</span>
</div>
` : ''}
${totalVersions > 1 ? `
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Version:</span>
<span class="cpm-graph-value">${versionIndex} / ${totalVersions}</span>
</div>
` : ''}
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Branch:</span>
<span class="cpm-graph-value">${branchNumber}</span>
</div>
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Status:</span>
<span class="cpm-graph-value cpm-graph-status-${branchInfo?.isMainBranch ? 'main' : 'side'}">${branchStatus}</span>
</div>
${stopReason ? `
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Stop Reason:</span>
<span class="cpm-graph-value">${stopReason}</span>
</div>
` : ''}
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Created:</span>
<span class="cpm-graph-value">${node.chainNodes ? createdDisplay : `${timeStr} (${node.createTime})`}</span>
</div>
${node.children.length >= 1 ? `
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Children:</span>
<span class="cpm-graph-value">${node.chainNodes ? childrenDisplay : node.children}</span>
</div>
` : ''}
${unmergedDisplay ? `
<div class="cpm-graph-info-row">
<span class="cpm-graph-label">Index:</span>
<span class="cpm-graph-value">${unmergedDisplay}</span>
</div>
` : ''}
</div>
${content ? `
<div class="cpm-graph-content-section">
<div class="cpm-graph-content-header">Content</div>
<div class="cpm-graph-content-box">
<div class="cpm-graph-content-text" id="content-preview">${escapeHtml(preview)}${hasMore ? '...' : ''}</div>
<div class="cpm-graph-content-text" id="content-full" style="display: none;">${escapeHtml(content)}</div>
</div>
${hasMore ? '<button class="cpm-graph-content-toggle" id="content-toggle">Show full text</button>' : ''}
</div>
` : ''}
<div class="cpm-graph-content-section">
<div class="cpm-graph-content-header">Raw JSON</div>
<div class="cpm-graph-content-box" style="display: none;" id="raw-json-box">
<pre class="cpm-graph-content-text">${escapeHtml(rawJSON)}</pre>
</div>
<button class="cpm-graph-content-toggle" id="json-toggle">Show Raw JSON</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Toggle full text
const toggleBtn = modal.querySelector('#content-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const preview = modal.querySelector('#content-preview');
const full = modal.querySelector('#content-full');
if (full.style.display === 'none') {
preview.style.display = 'none';
full.style.display = 'block';
toggleBtn.textContent = 'Show less';
} else {
preview.style.display = 'block';
full.style.display = 'none';
toggleBtn.textContent = 'Show full text';
}
});
}
// Toggle raw JSON
const jsonToggleBtn = modal.querySelector('#json-toggle');
const jsonBox = modal.querySelector('#raw-json-box');
if (jsonToggleBtn && jsonBox) {
jsonToggleBtn.addEventListener('click', () => {
if (jsonBox.style.display === 'none') {
jsonBox.style.display = 'block';
jsonToggleBtn.textContent = 'Hide Raw JSON';
} else {
jsonBox.style.display = 'none';
jsonToggleBtn.textContent = 'Show Raw JSON';
}
});
}
// Close modal
const closeModal = () => modal.remove();
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
const closeBtn = modal.querySelector('.cpm-graph-modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
}
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.open();
} else {
this.close();
}
}
async open() {
console.log(`${LOG_PREFIX} Opening tree panel`);
this.container.style.display = 'block';
this.container.style.width = `${this.panelWidth}px`;
this.isOpen = true;
const panelMode = Settings.get('graph', 'panelMode');
if (panelMode === 'push') {
const main = document.querySelector(SELECTORS.chat.mainContainer);
if (main) {
main.style.width = `calc(100% - ${this.panelWidth}px)`;
}
}
// Load tree if not loaded
if (!this.treeData) {
await this.loadConversationTree();
}
}
close() {
console.log(`${LOG_PREFIX} Closing tree panel`);
this.container.style.display = 'none';
this.isOpen = false;
const main = document.querySelector(SELECTORS.chat.mainContainer);
if (main) {
main.style.width = '100%';
}
}
async refresh() {
console.log(`${LOG_PREFIX} Refreshing tree`);
// Clear current tree data
this.treeData = null;
this.nodePositions.clear();
// Clear canvas
if (this.ctx && this.canvas) {
const content = this.container.querySelector('.tree-panel-content');
const rect = content.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
this.ctx.fillStyle = '#0f172a';
this.ctx.fillRect(0, 0, width, height);
this.ctx.fillStyle = '#94a3b8';
this.ctx.font = '14px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('Refreshing...', width / 2, height / 2);
}
await this.loadConversationTree();
}
openSettings() {
console.log(`${LOG_PREFIX} Opening settings`);
this.createSettingsModal();
}
createSettingsModal() {
// Remove existing modal
const existing = document.querySelector('.cpm-graph-modal');
if (existing) {
existing.remove();
}
const modal = document.createElement('div');
modal.className = 'cpm-graph-modal-overlay';
modal.innerHTML = `
<div class="cpm-graph-modal">
<div class="cpm-graph-modal-header">
<h3>Tree Settings</h3>
<button class="cpm-graph-modal-close">×</button>
</div>
<div class="cpm-graph-modal-body">
<div class="cpm-graph-settings-section">
<h4>Panel Mode</h4>
<select id="panel-mode">
<option value="overlay">Overlay</option>
<option value="push">Push Content</option>
</select>
</div>
<div class="cpm-graph-settings-section">
<h4>Assistant Chains</h4>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="merge-assistant-chains" style="width: 16px; height: 16px; cursor: pointer;">
<span>Merge system/tool/assistant nodes into one</span>
</label>
</div>
</div>
<div class="cpm-graph-modal-footer">
<button class="cpm-graph-btn cpm-graph-btn-primary">Save & Apply</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Load current settings
document.getElementById('panel-mode').value = Settings.get('graph', 'panelMode');
document.getElementById('merge-assistant-chains').checked = Settings.get('graph', 'mergeAssistantChains');
// Event listeners
modal.querySelector('.cpm-graph-modal-close').addEventListener('click', () => modal.remove());
modal.querySelector('.cpm-graph-btn-primary').addEventListener('click', () => {
this.saveSettings();
modal.remove();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
saveSettings() {
Settings.set('graph', 'panelMode', document.getElementById('panel-mode').value);
Settings.set('graph', 'mergeAssistantChains', document.getElementById('merge-assistant-chains').checked);
Settings.save();
console.log(`${LOG_PREFIX} Settings saved`);
this.loadConversationTree();
}
startResizing(e) {
this.isResizing = true;
document.documentElement.style.userSelect = 'none';
document.addEventListener('mousemove', (e) => this.resizePanel(e));
document.addEventListener('mouseup', () => this.stopResizing());
const resizer = this.container.querySelector('.tree-resizer-group');
if (resizer) resizer.classList.add('resizing');
e.preventDefault();
}
resizePanel(e) {
if (!this.isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth >= 300 && newWidth <= window.innerWidth - 100) {
this.panelWidth = newWidth;
this.container.style.width = `${newWidth}px`;
// Update push mode
const mode = Settings.get('graph', 'panelMode');
if (mode === 'push' && this.isOpen) {
const main = document.querySelector(SELECTORS.chat.mainContainer);
if (main) {
main.style.width = `calc(100% - ${newWidth}px)`;
}
}
// Re-render with new size
setTimeout(() => {
if (this.treeData) {
this.renderTree();
}
}, 10);
}
}
stopResizing() {
this.isResizing = false;
document.documentElement.style.userSelect = '';
document.removeEventListener('mousemove', (e) => this.resizePanel(e));
document.removeEventListener('mouseup', () => this.stopResizing());
const resizer = this.container.querySelector('.tree-resizer-group');
if (resizer) resizer.classList.remove('resizing');
}
}
// =============================================
// INITIALIZATION
// =============================================
let treePanel = null;
async function initialize() {
console.log(`${LOG_PREFIX} Initializing ChatGPT Conversation Tree`);
// Wait for page to load
await new Promise(resolve => setTimeout(resolve, CONFIG.initialDelay));
// Check if we're in a conversation
const conversationId = getConversationId();
if (!conversationId) {
console.log(`${LOG_PREFIX} Not in a conversation, waiting...`);
// Watch for navigation
watchForConversation();
return;
}
// Initialize tree panel
treePanel = new ConversationTreePanel();
await treePanel.initialize();
console.log(`${LOG_PREFIX} Initialization complete`);
}
function watchForConversation() {
// Watch URL changes
let lastUrl = window.location.href;
const observer = new MutationObserver(() => {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
const conversationId = getConversationId();
if (conversationId && !treePanel) {
console.log(`${LOG_PREFIX} Conversation detected, initializing...`);
initialize();
} else if (!conversationId && treePanel) {
console.log(`${LOG_PREFIX} Left conversation`);
if (treePanel.isOpen) {
treePanel.close();
}
} else if (conversationId && treePanel) {
console.log(`${LOG_PREFIX} Conversation changed, refreshing...`);
treePanel.refresh();
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// =============================================
// STYLES
// =============================================
GM_addStyle(`
/* Resizer */
.tree-resizer-group {
position: absolute;
top: 50%;
left: -7px;
width: 14px;
height: 100%;
transform: translateY(-50%);
cursor: col-resize;
z-index: 10;
user-select: none;
display: grid;
place-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.tree-resizer-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 0.5px;
background: rgba(148, 163, 184, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 50ms;
transform: translateX(-50%);
transform-origin: center;
}
.tree-resizer-handle {
position: relative;
width: 8px;
height: 24px;
border-radius: 9999px;
border: 0.5px solid rgba(148, 163, 184, 0.3);
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
cursor: col-resize;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
overflow: hidden;
}
.tree-resizer-handle::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 8px;
background: linear-gradient(
to bottom,
transparent,
rgba(148, 163, 184, 0.15) 20%,
rgba(148, 163, 184, 0.25) 50%,
rgba(148, 163, 184, 0.15) 80%,
transparent
);
border-radius: 1px;
transform: translate(-50%, -50%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tree-resizer-group:hover .tree-resizer-line {
background: linear-gradient(
to bottom,
transparent,
rgba(102, 126, 234, 0.4) 20%,
rgba(102, 126, 234, 0.7) 50%,
rgba(102, 126, 234, 0.4) 80%,
transparent
);
width: 2px;
box-shadow:
0 0 8px rgba(102, 126, 234, 0.3),
0 0 16px rgba(102, 126, 234, 0.1);
}
.tree-resizer-group:hover .tree-resizer-handle {
border-color: rgba(102, 126, 234, 0.6);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow:
0 0 0 2px rgba(102, 126, 234, 0.2),
0 2px 8px rgba(0, 0, 0, 0.3),
0 0 12px rgba(102, 126, 234, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.tree-resizer-group:hover .tree-resizer-handle::before {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0.2) 80%,
transparent
);
box-shadow: 0 0 4px rgba(255, 255, 255, 0.3);
}
.tree-resizer-group:active .tree-resizer-handle,
.tree-resizer-group.resizing .tree-resizer-handle {
border-color: rgba(118, 75, 162, 0.8);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow:
0 0 0 3px rgba(118, 75, 162, 0.4),
0 4px 12px rgba(0, 0, 0, 0.4),
0 0 20px rgba(118, 75, 162, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.tree-resizer-group:active .tree-resizer-line,
.tree-resizer-group.resizing .tree-resizer-line {
background: linear-gradient(
to bottom,
transparent,
rgba(118, 75, 162, 0.6) 10%,
rgba(118, 75, 162, 0.9) 50%,
rgba(118, 75, 162, 0.6) 90%,
transparent
);
width: 3px;
box-shadow:
0 0 12px rgba(118, 75, 162, 0.5),
0 0 24px rgba(118, 75, 162, 0.3);
}
/* Toggle Button */
.tree-toggle-btn {
position: fixed;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(14, 20, 30, 0.95) 0%, rgba(10, 17, 32, 0.9) 100%);
border: 1.5px solid rgba(59, 130, 246, 0.4);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
z-index: 9999;
}
.tree-toggle-btn:hover {
transform: scale(1.05);
border-color: rgba(59, 130, 246, 0.7);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.4),
0 0 40px rgba(59, 130, 246, 0.6),
0 0 60px rgba(59, 130, 246, 0.3);
}
.tree-toggle-btn svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
/* Panel Wrapper */
.tree-panel-wrapper {
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-left: 1px solid rgba(71, 85, 105, 0.3);
z-index: 9998;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
}
/* Panel */
.tree-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: #e2e8f0;
}
/* Header */
.tree-panel-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
background: rgba(30, 41, 59, 0.6);
}
.tree-header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.tree-header-content h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
}
.tree-header-controls {
display: flex;
gap: 8px;
}
.tree-control-btn {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(51, 65, 85, 0.5);
border: 1px solid rgba(71, 85, 105, 0.3);
color: #cbd5e1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.tree-control-btn:hover {
background: rgba(71, 85, 105, 0.6);
border-color: rgba(100, 116, 139, 0.5);
color: #f1f5f9;
}
/* Content */
.tree-panel-content {
flex: 1;
overflow: hidden;
position: relative;
}
.tree-loading, .tree-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #94a3b8;
font-size: 14px;
}
.tree-error {
color: #ef4444;
}
.tree-canvas {
width: 100%;
height: 100%;
cursor: grab;
}
.tree-canvas:active {
cursor: grabbing;
}
/* Modal Overlay */
.cpm-graph-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Modal Window */
.cpm-graph-modal {
background: linear-gradient(to bottom, #1e293b 0%, #0f172a 100%);
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(59, 130, 246, 0.2);
min-width: 420px;
max-width: 520px;
max-height: 85vh;
animation: slideUp 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Modal Header */
.cpm-graph-modal-header {
padding: 24px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-bottom: 1px solid rgba(59, 130, 246, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.cpm-graph-modal-header h3 {
margin: 0;
color: #e2e8f0;
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.cpm-graph-modal-close {
background: rgba(51, 65, 85, 0.5);
border: none;
color: #94a3b8;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
font-weight: 300;
}
.cpm-graph-modal-close:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
transform: rotate(90deg);
}
/* Modal Body */
.cpm-graph-modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.cpm-graph-modal-body::-webkit-scrollbar {
width: 6px;
}
.cpm-graph-modal-body::-webkit-scrollbar-track {
background: rgba(51, 65, 85, 0.3);
border-radius: 3px;
}
.cpm-graph-modal-body::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.5);
border-radius: 3px;
}
.cpm-graph-modal-body::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.7);
}
.cpm-graph-info-row {
display: flex;
margin-bottom: 14px;
align-items: flex-start;
padding: 8px 12px;
background: rgba(51, 65, 85, 0.3);
border-radius: 6px;
transition: background 0.2s;
}
.cpm-graph-info-row:hover {
background: rgba(51, 65, 85, 0.5);
transform: translateX(2px);
}
.cpm-graph-info-row:last-child {
margin-bottom: 0;
}
.cpm-graph-label {
color: #94a3b8;
font-size: 13px;
font-weight: 600;
min-width: 110px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 11px;
}
.cpm-graph-value {
color: #e2e8f0;
font-size: 13px;
font-family: 'Monaco', 'Menlo', monospace;
word-break: break-all;
line-height: 1.5;
}
/* Content Section */
.cpm-graph-content-section {
padding: 16px 24px;
border-top: 1px solid rgba(51, 65, 85, 0.5);
background: rgba(15, 23, 42, 0.3);
}
.cpm-graph-content-header {
color: #94a3b8;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.cpm-graph-content-box {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(71, 85, 105, 0.3);
border-radius: 8px;
padding: 14px;
max-height: 250px;
overflow-y: auto;
}
.cpm-graph-content-box::-webkit-scrollbar {
width: 6px;
}
.cpm-graph-content-box::-webkit-scrollbar-track {
background: rgba(51, 65, 85, 0.3);
border-radius: 3px;
}
.cpm-graph-content-box::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.5);
border-radius: 3px;
}
.cpm-graph-content-text {
color: #cbd5e1;
font-family: 'Roboto Mono', monospace;
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.cpm-graph-content-toggle {
margin-top: 8px;
padding: 6px 12px;
width: 100%;
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 6px;
color: #60a5fa;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cpm-graph-content-toggle:hover {
background: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.5);
}
.cpm-graph-status-main {
color: #60a5fa !important;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 12px;
background: rgba(59, 130, 246, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-family: sans-serif;
display: inline-block;
}
.cpm-graph-status-side {
color: #94a3b8 !important;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 12px;
background: rgba(100, 116, 139, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-family: sans-serif;
display: inline-block;
}
.cpm-graph-modal-footer {
padding: 16px 24px;
border-top: 1px solid rgba(71, 85, 105, 0.3);
background: rgba(30, 41, 59, 0.6);
}
.cpm-graph-btn.cpm-graph-btn-primary {
width: 100%;
padding: 10px 24px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.cpm-graph-btn.cpm-graph-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.cpm-graph-btn.cpm-graph-btn-primary:active {
transform: translateY(0);
}
/* Settings Modal */
.cpm-graph-settings-section {
margin-bottom: 20px;
}
.cpm-graph-settings-section:last-child {
margin-bottom: 0;
}
.cpm-graph-settings-section h4 {
margin: 0 0 10px 0;
color: #cbd5e1;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cpm-graph-settings-section select {
width: 100%;
padding: 10px 12px;
background: rgba(30, 41, 59, 0.6);
border: 1px solid rgba(71, 85, 105, 0.3);
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.cpm-graph-settings-section select:hover {
border-color: rgba(100, 116, 139, 0.5);
background: rgba(30, 41, 59, 0.8);
}
.cpm-graph-settings-section select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.cpm-graph-settings-section label {
display: flex;
align-items: center;
gap: 10px;
color: #cbd5e1;
font-size: 14px;
cursor: pointer;
}
.cpm-graph-settings-section input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.tree-panel-wrapper {
width: 100%;
}
.tree-toggle-btn {
bottom: 80px;
}
}
`);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();