visual-agentic-editor / index.html
LukasBe's picture
Add 2 files
900c817 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NovaFlow | Visual Agentic Editor (MVP)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Poppins', sans-serif;
background-color: #0f172a;
color: #e2e8f0;
overflow: hidden;
}
.gradient-bg {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
}
.node {
position: absolute;
min-width: 200px;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: grab;
user-select: none;
transition: all 0.2s ease;
}
.node:hover {
box-shadow: 0 0 0 2px #3b82f6;
}
.node.selected {
box-shadow: 0 0 0 2px #3b82f6;
border-color: #3b82f6;
}
.node-header {
padding: 10px 12px;
border-bottom: 1px solid #334155;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(30, 41, 59, 0.8);
border-radius: 12px 12px 0 0;
}
.node-content {
padding: 12px;
max-height: 150px; /* Added for result display */
overflow-y: auto; /* Added for result display */
}
.node-result { /* Added for result display */
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #334155;
font-size: 0.8rem;
white-space: pre-wrap; /* Show line breaks */
word-wrap: break-word;
color: #cbd5e1;
}
.connector {
width: 16px;
height: 16px;
border-radius: 50%;
background: #334155;
border: 2px solid #64748b;
cursor: pointer;
position: absolute;
z-index: 10;
}
.connector:hover {
background: #3b82f6;
border-color: #3b82f6;
}
.connector.input { left: -8px; }
.connector.output { right: -8px; }
.connection { position: absolute; pointer-events: none; z-index: 5; }
.connection-path { stroke: #64748b; stroke-width: 2; fill: none; }
.connection-path.active { stroke: #3b82f6; stroke-width: 3; }
.node-icon { width: 24px; height: 24px; border-radius: 6px; display: flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
.node-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1; }
.node-actions { display: flex; gap: 4px; }
.node-action-btn { width: 20px; height: 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #94a3b8; cursor: pointer; }
.node-action-btn:hover { background: #334155; color: #e2e8f0; }
.palette-item { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; cursor: grab; background: #1e293b; border: 1px solid #334155; display: flex; align-items: center; }
.palette-item:hover { background: #334155; }
/* --- Styles from original code omitted for brevity, assume they are here --- */
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #1e293b; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* Context menu */
.context-menu { position: absolute; background: #1e293b; border: 1px solid #334155; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); z-index: 100; min-width: 160px; overflow: hidden; }
.context-menu-item { padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.context-menu-item:hover { background: #334155; }
/* Tooltip */
.tooltip { position: absolute; background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 4px 8px; font-size: 12px; pointer-events: none; z-index: 100; white-space: nowrap; }
/* Status bar */
.status-bar { height: 24px; background: #1e293b; border-top: 1px solid #334155; display: flex; align-items: center; padding: 0 8px; font-size: 12px; color: #94a3b8; }
/* Zoom controls */
.zoom-controls { position: absolute; bottom: 32px; right: 16px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; z-index: 10; }
.zoom-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.zoom-btn:hover { background: #334155; }
/* Node property editor */
.property-editor { background: #1e293b; border-left: 1px solid #334155; height: 100%; overflow-y: auto; }
.property-group { border-bottom: 1px solid #334155; padding: 12px; }
.property-group.hidden { display: none; } /* To hide non-relevant groups */
.property-group-title { font-weight: 500; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.property-row { margin-bottom: 12px; }
.property-label { font-size: 12px; color: #94a3b8; margin-bottom: 4px; }
.property-input { width: 100%; background: #334155; border: 1px solid #475569; border-radius: 4px; padding: 6px 8px; color: #e2e8f0; }
.property-input:focus { outline: none; border-color: #3b82f6; }
.property-input:disabled { background: #475569; cursor: not-allowed; opacity: 0.6; }
textarea.property-input { resize: vertical; min-height: 60px;}
/* Tabs */
.tabs { display: flex; border-bottom: 1px solid #334155; }
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
.tab.active { border-bottom-color: #3b82f6; color: #3b82f6; }
.tab:hover:not(.active) { background: #334155; }
/* Dragging state */
.dragging-connection { position: absolute; pointer-events: none; z-index: 5; }
.dragging-connection-path { stroke: #3b82f6; stroke-width: 2; fill: none; }
/* Button disabled state */
button:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
</head>
<body class="min-h-screen gradient-bg">
<div class="flex flex-col h-screen">
<!-- Header -->
<header class="py-3 px-6 flex justify-between items-center border-b border-slate-700">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center">
<i class="fas fa-project-diagram text-white text-lg"></i>
</div>
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400">
NovaFlow
</h1>
<div class="text-sm text-slate-400">Visual Agentic Editor (MVP)</div>
</div>
<div class="flex space-x-4">
<button id="apiSettingsBtn" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-medium transition-colors flex items-center">
<i class="fas fa-key mr-2"></i> API Settings
</button>
<button id="runWorkflowBtn" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium transition-colors flex items-center" disabled>
<i class="fas fa-play mr-2"></i> Run Selected
</button>
<!-- Save button is non-functional for MVP -->
<button id="saveWorkflowBtn" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors flex items-center" disabled title="Save not implemented in MVP">
<i class="fas fa-save mr-2"></i> Save
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex overflow-hidden">
<!-- Left Sidebar - Node Palette -->
<aside class="w-64 bg-slate-800 border-r border-slate-700 overflow-y-auto flex flex-col">
<div class="p-4 border-b border-slate-700">
<h2 class="text-sm uppercase font-semibold text-slate-400 mb-3">Node Palette</h2>
<input type="text" placeholder="Search nodes..." class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="mb-6">
<h3 class="text-xs uppercase font-semibold text-slate-400 mb-2 flex items-center">
<i class="fas fa-brain mr-2"></i> AI Actions
</h3>
<div id="aiNodes">
<!-- Only LLM node is functional for MVP -->
<div class="palette-item" draggable="true" data-type="llm">
<div class="node-icon bg-blue-900 text-blue-300"><i class="fas fa-robot"></i></div>
<span>LLM Prompt</span>
</div>
<div class="palette-item opacity-50 cursor-not-allowed" draggable="false" title="Code Execution not implemented in MVP">
<div class="node-icon bg-purple-900 text-purple-300"><i class="fas fa-code"></i></div>
<span>Code Execution</span>
</div>
<div class="palette-item opacity-50 cursor-not-allowed" draggable="false" title="Decision not implemented in MVP">
<div class="node-icon bg-green-900 text-green-300"><i class="fas fa-code-branch"></i></div>
<span>Decision</span>
</div>
</div>
</div>
<!-- Other node categories disabled for MVP -->
<div class="mb-6 opacity-50">
<h3 class="text-xs uppercase font-semibold text-slate-400 mb-2 flex items-center">
<i class="fas fa-exchange-alt mr-2"></i> Data Processing
</h3>
<div><!-- Placeholder --></div>
</div>
<div class="opacity-50">
<h3 class="text-xs uppercase font-semibold text-slate-400 mb-2 flex items-center">
<i class="fas fa-plug mr-2"></i> Integrations
</h3>
<div><!-- Placeholder --></div>
</div>
</div>
</aside>
<!-- Canvas Area -->
<section class="flex-1 relative overflow-hidden" id="canvasContainer">
<div id="canvas" class="absolute w-full h-full bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iIzBmMTcyYSIvPjxwYXRoIGQ9Ik0xMCAxMHYxTTEwIDEwSDEwVjEwWiIgc3Ryb2tlPSIjMzM0MTU1IiBzdHJva2Utd2lkdGg9IjEiLz48L2c+PC9zdmc+')]">
<!-- Nodes will be added here dynamically -->
</div>
<!-- Zoom controls -->
<div class="zoom-controls">
<div class="zoom-btn" id="zoomInBtn"><i class="fas fa-search-plus"></i></div>
<div class="zoom-btn border-t border-slate-700" id="zoomOutBtn"><i class="fas fa-search-minus"></i></div>
<div class="zoom-btn border-t border-slate-700" id="zoomResetBtn"><i class="fas fa-expand"></i></div>
</div>
</section>
<!-- Right Sidebar - Property Editor -->
<aside class="w-80 bg-slate-800 border-l border-slate-700 overflow-y-auto flex flex-col">
<!-- Tabs (Simplified for MVP - Properties only visible relevantly) -->
<div class="p-4 border-b border-slate-700">
<h2 class="text-sm uppercase font-semibold text-slate-400">Properties</h2>
</div>
<div class="property-editor flex-1">
<div id="propertiesTab" class="tab-content active">
<!-- Generic Node Properties -->
<div class="property-group" id="genericProps">
<div class="property-group-title"><span>Node Properties</span></div>
<div class="property-row">
<div class="property-label">Node Name</div>
<input type="text" class="property-input" id="nodeNameInput" disabled>
</div>
<div class="property-row">
<div class="property-label">Node Type</div>
<input type="text" class="property-input" id="nodeTypeInput" disabled>
</div>
<div class="text-xs text-slate-500 mt-2" id="selectNodeMsg">Select a node to see its properties.</div>
</div>
<!-- LLM Specific Properties -->
<div class="property-group hidden" id="llmProps">
<div class="property-group-title"><span>LLM Configuration</span></div>
<div class="property-row">
<div class="property-label">Model</div>
<select class="property-input" id="modelSelect">
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option>
<option value="gpt-4">GPT-4</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="gpt-4o">GPT-4o</option>
</select>
</div>
<div class="property-row">
<div class="property-label">Temperature</div>
<input type="range" class="w-full" id="temperatureSlider" min="0" max="2" step="0.1" value="0.7">
<div class="flex justify-between text-xs text-slate-400 mt-1">
<span id="tempValueLabel">0.7</span>
<span>Precise <-> Creative</span>
</div>
</div>
<div class="property-row">
<div class="property-label">System Prompt</div>
<textarea class="property-input" id="systemPromptInput" rows="4" placeholder="e.g., You are a helpful assistant."></textarea>
</div>
<div class="property-row">
<div class="property-label">User Prompt</div>
<textarea class="property-input" id="userPromptInput" rows="5" placeholder="e.g., Write a poem about clouds."></textarea>
</div>
</div>
<!-- Input/Output Properties (Hidden for MVP) -->
<div class="property-group hidden" id="ioProps">
<div class="property-group-title"><span>Input/Output (N/A for MVP)</span></div>
<!-- Content hidden -->
</div>
</div>
<!-- Workflow Tab (Hidden for MVP) -->
<div id="workflowTab" class="tab-content hidden"></div>
</div>
</aside>
</main>
<!-- Status Bar -->
<footer class="status-bar">
<div class="flex-1" id="statusBarText">Ready</div>
<div class="flex items-center space-x-4">
<span id="statusBarZoom">Zoom: 100%</span>
<span id="statusBarSelected">Selected: None</span>
<span id="statusBarTotal">Total: 0 nodes</span>
</div>
</footer>
</div>
<!-- API Settings Modal -->
<div id="apiSettingsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-slate-800 rounded-xl max-w-md w-full p-6 border border-slate-700">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">API Settings</h3>
<button id="closeApiSettingsBtn" class="p-2 rounded-full hover:bg-slate-700"><i class="fas fa-times"></i></button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">OpenAI API Key</label>
<div class="flex space-x-2">
<input type="password" id="apiKeyInput" placeholder="sk-...your-api-key" class="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<button id="toggleApiKeyVisibility" class="p-2 rounded-lg bg-slate-700 hover:bg-slate-600"><i class="fas fa-eye"></i></button>
</div>
<p class="text-xs text-slate-400 mt-1">Required to run LLM nodes. Key is stored locally in your browser.</p>
<p class="text-xs text-slate-400 mt-1">Manage keys at <a href="https://platform.openai.com/account/api-keys" target="_blank" class="text-blue-400 hover:underline">OpenAI Platform</a>.</p>
</div>
<div class="pt-4 border-t border-slate-700 flex justify-end">
<button id="saveApiSettingsBtn" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors">
Save Settings
</button>
</div>
</div>
</div>
</div>
<!-- Context Menu (Simplified for MVP) -->
<div id="contextMenu" class="context-menu hidden">
<div class="context-menu-item" data-action="delete">
<i class="fas fa-trash text-red-400 w-4 text-center"></i>
<span>Delete</span>
</div>
<!-- Other actions disabled for MVP -->
<div class="context-menu-item opacity-50 cursor-not-allowed" title="Not implemented in MVP">
<i class="fas fa-copy text-blue-400 w-4 text-center"></i>
<span>Duplicate</span>
</div>
<div class="context-menu-item opacity-50 cursor-not-allowed" title="Not implemented in MVP">
<i class="fas fa-clipboard text-green-400 w-4 text-center"></i>
<span>Copy</span>
</div>
<div class="context-menu-item opacity-50 cursor-not-allowed" title="Not implemented in MVP">
<i class="fas fa-paste text-yellow-400 w-4 text-center"></i>
<span>Paste</span>
</div>
</div>
<!-- Tooltip -->
<div id="tooltip" class="tooltip hidden"></div>
<!-- Connection Elements (Visual only for MVP) -->
<svg id="draggingConnection" class="dragging-connection hidden" width="100%" height="100%"><path class="dragging-connection-path"></path></svg>
<svg id="connections" class="connection" width="100%" height="100%"></svg>
<script>
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const canvas = document.getElementById('canvas');
const canvasContainer = document.getElementById('canvasContainer');
const paletteItems = document.querySelectorAll('.palette-item[draggable="true"]'); // Only draggable items
const apiSettingsBtn = document.getElementById('apiSettingsBtn');
const closeApiSettingsBtn = document.getElementById('closeApiSettingsBtn');
const apiSettingsModal = document.getElementById('apiSettingsModal');
const apiKeyInput = document.getElementById('apiKeyInput');
const toggleApiKeyVisibility = document.getElementById('toggleApiKeyVisibility');
const saveApiSettingsBtn = document.getElementById('saveApiSettingsBtn');
const runWorkflowBtn = document.getElementById('runWorkflowBtn');
// const saveWorkflowBtn = document.getElementById('saveWorkflowBtn'); // Not functional
const contextMenu = document.getElementById('contextMenu');
const tooltip = document.getElementById('tooltip');
const zoomInBtn = document.getElementById('zoomInBtn');
const zoomOutBtn = document.getElementById('zoomOutBtn');
const zoomResetBtn = document.getElementById('zoomResetBtn');
const draggingConnection = document.getElementById('draggingConnection');
const connectionsSvg = document.getElementById('connections'); // Renamed for clarity
// Property Editor Elements
const genericPropsGroup = document.getElementById('genericProps');
const llmPropsGroup = document.getElementById('llmProps');
const ioPropsGroup = document.getElementById('ioProps'); // Though hidden
const nodeNameInput = document.getElementById('nodeNameInput');
const nodeTypeInput = document.getElementById('nodeTypeInput');
const modelSelect = document.getElementById('modelSelect');
const temperatureSlider = document.getElementById('temperatureSlider');
const tempValueLabel = document.getElementById('tempValueLabel');
const systemPromptInput = document.getElementById('systemPromptInput');
const userPromptInput = document.getElementById('userPromptInput');
const selectNodeMsg = document.getElementById('selectNodeMsg');
// Status Bar Elements
const statusBarText = document.getElementById('statusBarText');
const statusBarZoom = document.getElementById('statusBarZoom');
const statusBarSelected = document.getElementById('statusBarSelected');
const statusBarTotal = document.getElementById('statusBarTotal');
// State
let selectedNodeElement = null; // The DOM element
let nodes = []; // Array of node data objects { id, type, x, y, properties: {...} }
let connectionsList = []; // Array of connection data objects { id, fromNode, toNode }
let isDraggingNode = false;
let isDraggingCanvas = false;
let dragStartX, dragStartY;
let nodeOffsetX, nodeOffsetY; // Offset within the node being dragged
let canvasOffsetX = 0, canvasOffsetY = 0;
let scale = 1;
let isDraggingConnection = false;
let connectionStart = null;
// let copiedNodeData = null; // Not used in MVP
let apiKey = null; // Holds the validated API key
let nodeCounter = 0; // Simple ID generator
const API_KEY_STORAGE_KEY = 'novaFlow_apiKey_mvp';
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
// --- Initialization ---
function initializeApp() {
loadApiKey();
updateRunButtonState();
updateStatusBar();
// Add event listeners
paletteItems.forEach(item => {
item.addEventListener('dragstart', handlePaletteDragStart);
});
canvasContainer.addEventListener('dragover', handleCanvasDragOver); // Use container for drop coordinates
canvasContainer.addEventListener('drop', handleCanvasDrop);
canvasContainer.addEventListener('mousedown', handleCanvasMouseDown); // Use container for panning start
canvasContainer.addEventListener('mousemove', handleCanvasMouseMove); // For tooltips mainly
canvasContainer.addEventListener('wheel', handleCanvasWheel, { passive: false });
canvasContainer.addEventListener('contextmenu', handleContextMenu);
apiSettingsBtn.addEventListener('click', () => apiSettingsModal.classList.remove('hidden'));
closeApiSettingsBtn.addEventListener('click', () => apiSettingsModal.classList.add('hidden'));
saveApiSettingsBtn.addEventListener('click', saveApiSettings);
toggleApiKeyVisibility.addEventListener('click', toggleApiKeyVisibilityHandler);
runWorkflowBtn.addEventListener('click', runWorkflow);
zoomInBtn.addEventListener('click', () => zoomCanvas(1.2));
zoomOutBtn.addEventListener('click', () => zoomCanvas(0.8));
zoomResetBtn.addEventListener('click', resetZoom);
document.addEventListener('mousemove', handleDocumentMouseMove); // For dragging nodes/canvas
document.addEventListener('mouseup', handleDocumentMouseUp); // Stop drags
document.addEventListener('click', closeContextMenu);
document.addEventListener('keydown', handleKeyDown);
// Add listeners to property editor inputs to update node data
nodeNameInput.addEventListener('input', updateNodeProperty);
modelSelect.addEventListener('change', updateNodeProperty);
temperatureSlider.addEventListener('input', updateNodeProperty);
systemPromptInput.addEventListener('input', updateNodeProperty);
userPromptInput.addEventListener('input', updateNodeProperty);
// Initial sample node (optional)
// const sampleNode = createNode('llm', 150, 80);
// selectNode(sampleNode); // Select it initially
}
// --- API Key Management ---
function loadApiKey() {
const storedKey = localStorage.getItem(API_KEY_STORAGE_KEY);
if (storedKey && storedKey.startsWith('sk-')) {
apiKey = storedKey;
apiKeyInput.value = storedKey; // Keep it masked initially or show placeholder
console.log("API Key loaded from localStorage.");
} else {
console.log("No valid API Key found in localStorage.");
}
}
function saveApiSettings() {
const key = apiKeyInput.value.trim();
if (!key) {
alert('Please enter your OpenAI API key.');
return;
}
if (!key.startsWith('sk-')) {
alert('Invalid API key format. It should start with "sk-".');
return;
}
apiKey = key;
localStorage.setItem(API_KEY_STORAGE_KEY, key);
apiSettingsModal.classList.add('hidden');
updateRunButtonState();
alert('API Key saved successfully.');
console.log("API Key saved.");
}
function toggleApiKeyVisibilityHandler() {
const type = apiKeyInput.getAttribute('type') === 'password' ? 'text' : 'password';
apiKeyInput.setAttribute('type', type);
this.innerHTML = type === 'password' ? '<i class="fas fa-eye"></i>' : '<i class="fas fa-eye-slash"></i>';
}
function updateRunButtonState() {
runWorkflowBtn.disabled = !apiKey || !selectedNodeElement || selectedNodeElement.dataset.type !== 'llm';
if (!apiKey) {
runWorkflowBtn.title = "Set API Key in Settings to run";
} else if (!selectedNodeElement || selectedNodeElement.dataset.type !== 'llm') {
runWorkflowBtn.title = "Select an LLM node to run";
} else {
runWorkflowBtn.title = "Run the selected LLM node";
}
}
// --- Node Creation & Management ---
function handlePaletteDragStart(e) {
e.dataTransfer.setData('text/plain', e.target.dataset.type);
e.dataTransfer.effectAllowed = 'copy';
}
function handleCanvasDragOver(e) {
e.preventDefault(); // Necessary to allow drop
e.dataTransfer.dropEffect = 'copy';
}
function handleCanvasDrop(e) {
e.preventDefault();
const nodeType = e.dataTransfer.getData('text/plain');
if (!nodeType) return;
// Calculate drop position relative to the canvas element itself, considering pan/zoom
const rect = canvas.getBoundingClientRect(); // Use canvas's rect for relative coords
const canvasX = (e.clientX - rect.left) / scale;
const canvasY = (e.clientY - rect.top) / scale;
createNode(nodeType, canvasX, canvasY);
}
function createNode(type, x, y) {
nodeCounter++;
const nodeId = `node-${nodeCounter}-${Date.now()}`;
const nodeColors = { // Simplified for MVP
'llm': { bg: 'bg-blue-900', text: 'text-blue-300', icon: 'fa-robot' }
// Other types omitted
};
const defaultTitles = {
'llm': 'LLM Prompt'
};
if (!nodeColors[type]) {
console.error("Unknown node type:", type);
return null;
}
const nodeElement = document.createElement('div');
nodeElement.className = `node w-64`; // Standard width for now
nodeElement.id = nodeId;
nodeElement.style.left = `${x}px`;
nodeElement.style.top = `${y}px`;
nodeElement.dataset.type = type;
nodeElement.innerHTML = `
<div class="node-header ${nodeColors[type].text}">
<div class="flex items-center flex-grow min-w-0"> <!-- Added min-w-0 for ellipsis -->
<div class="node-icon ${nodeColors[type].bg} ${nodeColors[type].text}">
<i class="fas ${nodeColors[type].icon}"></i>
</div>
<div class="node-title">${defaultTitles[type]}</div>
</div>
<div class="node-actions">
<!-- <div class="node-action-btn" data-action="settings" title="Configure (Select node)">
<i class="fas fa-cog"></i>
</div> -->
<div class="node-action-btn" data-action="delete" title="Delete Node">
<i class="fas fa-times text-red-400"></i>
</div>
</div>
</div>
<div class="node-content">
<div class="text-xs text-slate-400 mb-2">Configure in Properties panel.</div>
<div class="node-result text-xs text-slate-400 italic">Output will appear here...</div> <!-- Placeholder for results -->
</div>
<!-- Connectors (Visual only for MVP) -->
<div class="connector input" data-node-id="${nodeId}" data-type="input"></div>
<div class="connector output" data-node-id="${nodeId}" data-type="output"></div>
`;
canvas.appendChild(nodeElement);
// Create node data object
const nodeData = {
id: nodeId,
type: type,
x: x,
y: y,
properties: { // Default properties
name: defaultTitles[type],
// LLM specific defaults
model: 'gpt-3.5-turbo',
temperature: 0.7,
systemPrompt: '',
userPrompt: '',
}
};
nodes.push(nodeData);
// Add event listeners to the new node
nodeElement.addEventListener('mousedown', handleNodeMouseDown);
nodeElement.querySelector('.node-header').addEventListener('dblclick', () => nodeNameInput.select()); // Select text on double click
// Add listeners to action buttons
nodeElement.querySelectorAll('.node-action-btn').forEach(btn => {
btn.addEventListener('click', handleNodeActionClick);
});
// Add listeners to connectors (visual only for MVP)
nodeElement.querySelectorAll('.connector').forEach(conn => {
conn.addEventListener('mousedown', startConnectionDrag);
});
updateStatusBar();
return nodeElement;
}
function deleteNode(nodeElementToDelete) {
if (!nodeElementToDelete) return;
const nodeId = nodeElementToDelete.id;
// Remove from nodes array
nodes = nodes.filter(n => n.id !== nodeId);
// Remove connections involving this node (visual only cleanup needed)
connectionsList = connectionsList.filter(conn => conn.fromNode !== nodeId && conn.toNode !== nodeId);
updateConnectionsVisuals(); // Update SVG
// Remove from DOM
nodeElementToDelete.remove();
// Clear selection if this node was selected
if (selectedNodeElement && selectedNodeElement.id === nodeId) {
deselectNode();
}
updateStatusBar();
console.log(`Node ${nodeId} deleted.`);
}
function handleNodeActionClick(e) {
e.stopPropagation(); // Prevent node selection/drag start
const button = e.currentTarget;
const action = button.dataset.action;
const nodeElement = button.closest('.node');
if (action === 'delete') {
if (confirm(`Are you sure you want to delete node "${nodes.find(n=>n.id === nodeElement.id)?.properties?.name || nodeElement.id}"?`)) {
deleteNode(nodeElement);
}
}
// Other actions like settings can be handled here if needed
}
// --- Selection & Property Editor ---
function selectNode(nodeElement) {
if (selectedNodeElement) {
selectedNodeElement.classList.remove('selected');
}
selectedNodeElement = nodeElement;
selectedNodeElement.classList.add('selected');
const nodeData = nodes.find(n => n.id === selectedNodeElement.id);
if (nodeData) {
// Update generic properties
genericPropsGroup.classList.remove('hidden');
selectNodeMsg.classList.add('hidden');
nodeNameInput.value = nodeData.properties.name || '';
nodeTypeInput.value = nodeData.type;
nodeNameInput.disabled = false;
// Update LLM properties if applicable
if (nodeData.type === 'llm') {
llmPropsGroup.classList.remove('hidden');
ioPropsGroup.classList.add('hidden'); // Hide IO for LLM
modelSelect.value = nodeData.properties.model || 'gpt-3.5-turbo';
temperatureSlider.value = nodeData.properties.temperature || 0.7;
tempValueLabel.textContent = temperatureSlider.value;
systemPromptInput.value = nodeData.properties.systemPrompt || '';
userPromptInput.value = nodeData.properties.userPrompt || '';
} else {
// Hide LLM props if not an LLM node
llmPropsGroup.classList.add('hidden');
ioPropsGroup.classList.remove('hidden'); // Show generic IO section
}
} else {
// Should not happen if nodeElement exists, but good practice
deselectNode();
}
updateRunButtonState();
updateStatusBar();
}
function deselectNode() {
if (selectedNodeElement) {
selectedNodeElement.classList.remove('selected');
}
selectedNodeElement = null;
// Reset property editor
genericPropsGroup.classList.remove('hidden'); // Keep generic visible
selectNodeMsg.classList.remove('hidden');
llmPropsGroup.classList.add('hidden');
ioPropsGroup.classList.add('hidden');
nodeNameInput.value = '';
nodeTypeInput.value = '';
nodeNameInput.disabled = true;
updateRunButtonState();
updateStatusBar();
}
function updateNodeProperty(event) {
if (!selectedNodeElement) return;
const nodeData = nodes.find(n => n.id === selectedNodeElement.id);
if (!nodeData) return;
const inputElement = event.target;
const propertyName = inputElement.id.replace('Input', '').replace('Select', '').replace('Slider', ''); // e.g. "nodeName", "model", "temperature"
let value = inputElement.value;
if (inputElement.type === 'range') {
value = parseFloat(inputElement.value);
tempValueLabel.textContent = value; // Update label for slider
}
// Update the node data object
if (propertyName === 'nodeName') {
nodeData.properties.name = value;
// Also update the visual title on the node
selectedNodeElement.querySelector('.node-title').textContent = value || '(Untitled)';
} else if (propertyName === 'model') {
nodeData.properties.model = value;
} else if (propertyName === 'temperature') {
nodeData.properties.temperature = value;
} else if (propertyName === 'systemPrompt') {
nodeData.properties.systemPrompt = value;
} else if (propertyName === 'userPrompt') {
nodeData.properties.userPrompt = value;
}
// Add other properties here if needed later
// console.log(`Updated ${nodeData.id} property ${propertyName} to:`, value);
}
// --- Dragging & Panning ---
function handleNodeMouseDown(e) {
if (e.button !== 0 || e.target.classList.contains('connector') || e.target.closest('.node-action-btn')) return; // Left click only, ignore connectors/actions
e.stopPropagation();
const nodeElement = e.currentTarget;
selectNode(nodeElement); // Select node on click
isDraggingNode = true;
isDraggingCanvas = false; // Ensure canvas panning stops
dragStartX = e.clientX;
dragStartY = e.clientY;
// Calculate offset of mouse click relative to node's top-left corner
const nodeRect = nodeElement.getBoundingClientRect();
// Adjust for current canvas scale and offset
nodeOffsetX = (e.clientX - nodeRect.left) / scale;
nodeOffsetY = (e.clientY - nodeRect.top) / scale;
nodeElement.style.cursor = 'grabbing';
nodeElement.style.zIndex = 11; // Bring node above others while dragging
}
function handleCanvasMouseDown(e) {
// Only pan if clicking directly on the canvas background (or container)
if (e.button !== 0 || e.target !== canvasContainer && e.target !== canvas) return;
isDraggingCanvas = true;
isDraggingNode = false; // Ensure node dragging stops
dragStartX = e.clientX;
dragStartY = e.clientY;
canvasContainer.style.cursor = 'grabbing';
deselectNode(); // Deselect nodes when clicking background
}
function handleDocumentMouseMove(e) {
if (isDraggingNode && selectedNodeElement) {
const dx = (e.clientX - dragStartX) / scale;
const dy = (e.clientY - dragStartY) / scale;
const nodeData = nodes.find(n => n.id === selectedNodeElement.id);
if (nodeData) {
nodeData.x += dx;
nodeData.y += dy;
selectedNodeElement.style.left = `${nodeData.x}px`;
selectedNodeElement.style.top = `${nodeData.y}px`;
updateConnectionsVisuals(); // Update connection lines
}
dragStartX = e.clientX;
dragStartY = e.clientY;
} else if (isDraggingCanvas) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
canvasOffsetX += dx;
canvasOffsetY += dy;
applyCanvasTransform(); // Apply transform to canvas
dragStartX = e.clientX;
dragStartY = e.clientY;
updateConnectionsVisuals(); // Update visuals based on pan
} else if (isDraggingConnection) {
updateConnectionDrag(e);
}
// Update tooltip position
updateTooltipPosition(e);
}
function handleDocumentMouseUp(e) {
if (e.button !== 0) return;
if (isDraggingNode && selectedNodeElement) {
selectedNodeElement.style.cursor = 'grab';
selectedNodeElement.style.zIndex = ''; // Reset z-index
}
if (isDraggingCanvas) {
canvasContainer.style.cursor = 'default';
}
if (isDraggingConnection) {
completeConnectionDrag(e);
}
isDraggingNode = false;
isDraggingCanvas = false;
isDraggingConnection = false; // Ensure this is reset
draggingConnection.classList.add('hidden'); // Hide drag line
}
// --- Zooming ---
function handleCanvasWheel(e) {
e.preventDefault();
const delta = -e.deltaY;
const zoomFactor = delta > 0 ? 1.1 : 1 / 1.1; // Consistent zoom factor
// Calculate mouse position relative to the container top-left
const rect = canvasContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
zoomCanvas(zoomFactor, mouseX, mouseY);
}
function zoomCanvas(zoomFactor, pivotX, pivotY) {
const oldScale = scale;
let newScale = scale * zoomFactor;
newScale = Math.min(Math.max(0.2, newScale), 3); // Zoom limits
if (newScale === oldScale) return;
// Calculate new offset to keep the pivot point stationary
// The pivot point's position relative to the *unscaled* canvas origin
const worldX = (pivotX - canvasOffsetX) / oldScale;
const worldY = (pivotY - canvasOffsetY) / oldScale;
// The new offset required to keep worldX, worldY at pivotX, pivotY under newScale
canvasOffsetX = pivotX - worldX * newScale;
canvasOffsetY = pivotY - worldY * newScale;
scale = newScale;
applyCanvasTransform();
updateStatusBar();
updateConnectionsVisuals(); // Lines need to redraw after zoom/pan
}
function resetZoom() {
scale = 1;
canvasOffsetX = 0;
canvasOffsetY = 0;
applyCanvasTransform();
updateStatusBar();
updateConnectionsVisuals();
}
function applyCanvasTransform() {
// Apply transform to the inner canvas div
canvas.style.transform = `translate(${canvasOffsetX}px, ${canvasOffsetY}px) scale(${scale})`;
// Apply inverted transform to connection SVGs so they cover the screen correctly
const svgTransform = `translate(${-canvasOffsetX}px, ${-canvasOffsetY}px) scale(${1/scale})`;
// connectionsSvg.style.transform = svgTransform; // May not be needed if coords are absolute
// draggingConnection.style.transform = svgTransform;
}
// --- Connections (Visuals Only for MVP) ---
function startConnectionDrag(e) {
if (e.button !== 0) return;
e.stopPropagation();
isDraggingConnection = true;
isDraggingNode = false; // Prevent node drag
const connector = e.currentTarget;
const nodeId = connector.dataset.nodeId;
const type = connector.dataset.type; // 'input' or 'output'
const rect = connector.getBoundingClientRect();
// Use absolute screen coordinates for drawing the drag line initially
connectionStart = {
nodeId: nodeId,
type: type,
startX: rect.left + rect.width / 2,
startY: rect.top + rect.height / 2
};
// Initialize the dragging path
const path = draggingConnection.querySelector('.dragging-connection-path');
path.setAttribute('d', `M${connectionStart.startX},${connectionStart.startY} L${connectionStart.startX},${connectionStart.startY}`);
draggingConnection.classList.remove('hidden');
//console.log("Start connection drag from:", connectionStart);
}
function updateConnectionDrag(e) {
if (!isDraggingConnection || !connectionStart) return;
const path = draggingConnection.querySelector('.dragging-connection-path');
const startX = connectionStart.startX;
const startY = connectionStart.startY;
const endX = e.clientX; // Current mouse coords
const endY = e.clientY;
// Calculate control points for Bezier curve
const dx = Math.abs(startX - endX) * 0.6; // Adjust for curve shape
const c1x = startX + (connectionStart.type === 'output' ? dx : -dx);
const c1y = startY;
const c2x = endX + (connectionStart.type === 'input' ? dx : -dx); // Opposite direction for target
const c2y = endY;
path.setAttribute('d', `M${startX},${startY} C${c1x},${c1y} ${c2x},${c2y} ${endX},${endY}`);
}
function completeConnectionDrag(e) {
if (!isDraggingConnection || !connectionStart) return;
isDraggingConnection = false;
draggingConnection.classList.add('hidden');
// Check if dropped on a valid connector
const endElement = document.elementFromPoint(e.clientX, e.clientY);
let targetConnector = null;
if (endElement && endElement.classList.contains('connector')) {
targetConnector = endElement;
} else if (endElement?.parentElement && endElement.parentElement.classList.contains('connector')) {
// Sometimes the icon inside might be the target
targetConnector = endElement.parentElement;
}
if (targetConnector) {
const targetNodeId = targetConnector.dataset.nodeId;
const targetType = targetConnector.dataset.type; // 'input' or 'output'
// --- Validation ---
// 1. Different nodes?
if (targetNodeId === connectionStart.nodeId) {
console.log("Cannot connect node to itself.");
connectionStart = null;
return;
}
// 2. Different types (input to output or vice versa)?
if (targetType === connectionStart.type) {
console.log(`Cannot connect ${connectionStart.type} to ${targetType}.`);
connectionStart = null;
return;
}
// 3. Avoid duplicate connections (optional but good)
const exists = connectionsList.some(conn =>
(conn.fromNode === connectionStart.nodeId && conn.toNode === targetNodeId) ||
(conn.fromNode === targetNodeId && conn.toNode === connectionStart.nodeId)
);
if (exists) {
console.log("Connection already exists.");
connectionStart = null;
return;
}
// --- Create Connection Data ---
const newConnection = {
id: `conn-${Date.now()}`,
fromNode: connectionStart.type === 'output' ? connectionStart.nodeId : targetNodeId,
toNode: connectionStart.type === 'output' ? targetNodeId : connectionStart.nodeId,
};
connectionsList.push(newConnection);
console.log("Connection created:", newConnection);
updateConnectionsVisuals(); // Redraw all connections
} else {
console.log("Connection drag ended without target.");
}
connectionStart = null; // Reset
}
function updateConnectionsVisuals() {
connectionsSvg.innerHTML = ''; // Clear existing paths
connectionsList.forEach(conn => {
const fromNodeEl = document.getElementById(conn.fromNode);
const toNodeEl = document.getElementById(conn.toNode);
if (!fromNodeEl || !toNodeEl) {
console.warn(`Could not find nodes for connection ${conn.id}`);
// Optionally remove this connection from list if nodes are gone
// connectionsList = connectionsList.filter(c => c.id !== conn.id);
return;
}
const outputConnector = fromNodeEl.querySelector('.connector.output');
const inputConnector = toNodeEl.querySelector('.connector.input');
if (!outputConnector || !inputConnector) {
console.warn(`Could not find connectors for connection ${conn.id}`);
return;
}
// Get GLOBAL screen coordinates of connectors centers
const outRect = outputConnector.getBoundingClientRect();
const inRect = inputConnector.getBoundingClientRect();
const startX = outRect.left + outRect.width / 2;
const startY = outRect.top + outRect.height / 2;
const endX = inRect.left + inRect.width / 2;
const endY = inRect.top + inRect.height / 2;
// Calculate control points (same logic as dragging)
const dx = Math.abs(startX - endX) * 0.6;
const c1x = startX + dx;
const c1y = startY;
const c2x = endX - dx;
const c2y = endY;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.classList.add('connection-path');
path.setAttribute('d', `M${startX},${startY} C${c1x},${c1y} ${c2x},${c2y} ${endX},${endY}`);
path.dataset.connId = conn.id;
// Highlight if connected to selected node
if (selectedNodeElement && (conn.fromNode === selectedNodeElement.id || conn.toNode === selectedNodeElement.id)) {
path.classList.add('active');
}
connectionsSvg.appendChild(path);
});
}
// --- Workflow Execution (MVP: Selected LLM Node) ---
async function runWorkflow() {
if (!apiKey) {
alert('Please set your OpenAI API key in Settings first.');
apiSettingsModal.classList.remove('hidden');
return;
}
if (!selectedNodeElement) {
alert('Please select an LLM node to run.');
return;
}
if (selectedNodeElement.dataset.type !== 'llm') {
alert('The selected node is not an LLM node.');
return;
}
const nodeData = nodes.find(n => n.id === selectedNodeElement.id);
if (!nodeData) {
alert('Error: Could not find data for the selected node.');
return;
}
const runButtonIcon = runWorkflowBtn.querySelector('i');
const originalIconClass = runButtonIcon.className;
const originalButtonText = runWorkflowBtn.textContent.trim().split(" ").slice(1).join(" "); // Get text after icon
// Start loading state
runWorkflowBtn.disabled = true;
runWorkflowBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i> Running...`;
statusBarText.textContent = `Running node ${nodeData.properties.name}...`;
const resultElement = selectedNodeElement.querySelector('.node-result');
resultElement.textContent = 'Processing...';
resultElement.classList.remove('text-red-400'); // Clear previous errors
try {
const result = await executeLlmNode(nodeData, apiKey);
// Display result
resultElement.textContent = result;
resultElement.classList.remove('italic', 'text-slate-400');
statusBarText.textContent = `Node ${nodeData.properties.name} completed successfully.`;
console.log("LLM Result:", result);
} catch (error) {
console.error('Workflow execution failed:', error);
const errorMessage = error.message || 'An unknown error occurred.';
alert(`Error executing LLM node: ${errorMessage}`);
resultElement.textContent = `Error: ${errorMessage}`;
resultElement.classList.add('text-red-400'); // Indicate error
statusBarText.textContent = `Error running node ${nodeData.properties.name}.`;
} finally {
// End loading state
runWorkflowBtn.innerHTML = `<i class="${originalIconClass} mr-2"></i> ${originalButtonText}`;
updateRunButtonState(); // Re-evaluate if it should be enabled
}
}
async function executeLlmNode(nodeData, key) {
if (!nodeData || nodeData.type !== 'llm') {
throw new Error("Invalid node data provided for LLM execution.");
}
const { model, temperature, systemPrompt, userPrompt } = nodeData.properties;
if (!userPrompt) {
throw new Error("User Prompt cannot be empty.");
}
const messages = [];
if (systemPrompt && systemPrompt.trim() !== '') {
messages.push({ role: "system", content: systemPrompt.trim() });
}
messages.push({ role: "user", content: userPrompt.trim() });
console.log("Sending to OpenAI:", { model, messages, temperature });
const requestBody = {
model: model,
messages: messages,
temperature: temperature,
};
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
let errorDetails = `HTTP error! status: ${response.status}`;
try {
const errorData = await response.json();
errorDetails += ` - ${errorData.error?.message || JSON.stringify(errorData)}`;
} catch (e) {
// Failed to parse error JSON
errorDetails += ` - Failed to parse error response.`;
}
console.error("OpenAI API Error:", errorDetails);
throw new Error(errorDetails);
}
const data = await response.json();
if (!data.choices || data.choices.length === 0 || !data.choices[0].message || !data.choices[0].message.content) {
console.error("Invalid response structure from OpenAI:", data);
throw new Error("Received an invalid or empty response from OpenAI.");
}
return data.choices[0].message.content.trim();
}
// --- UI Updates & Helpers ---
function updateStatusBar() {
statusBarZoom.textContent = `Zoom: ${Math.round(scale * 100)}%`;
statusBarSelected.textContent = selectedNodeElement ? `Selected: ${selectedNodeElement.id}` : 'Selected: None';
statusBarTotal.textContent = `Total: ${nodes.length} nodes`;
// statusBarText updated during run
}
function handleContextMenu(e) {
e.preventDefault();
closeContextMenu(); // Close any existing
const targetIsNode = e.target.closest('.node');
const targetIsCanvas = e.target === canvas || e.target === canvasContainer;
// Show context menu at mouse position
// Only show if clicking on a node or the canvas background
if (targetIsNode || targetIsCanvas) {
contextMenu.style.left = `${e.clientX}px`;
contextMenu.style.top = `${e.clientY}px`;
contextMenu.classList.remove('hidden');
// Enable/disable items based on context
const deleteItem = contextMenu.querySelector('[data-action="delete"]');
// Enable delete only if a node is clicked
if (targetIsNode) {
deleteItem.classList.remove('opacity-50', 'cursor-not-allowed');
deleteItem.title = '';
// Select the node that was right-clicked
selectNode(targetIsNode);
} else {
deleteItem.classList.add('opacity-50', 'cursor-not-allowed');
deleteItem.title = 'Right-click on a node to delete';
}
// Store position for potential paste (not used in MVP)
contextMenu.dataset.contextX = e.clientX;
contextMenu.dataset.contextY = e.clientY;
} else {
// Clicked on something else (scrollbar, etc.), don't show menu
closeContextMenu();
}
}
function closeContextMenu(e) {
// Hide if clicking outside the menu, unless the click is part of opening it
if (e && e.target.closest('.context-menu')) {
// Handle context menu item click
const item = e.target.closest('.context-menu-item');
if (item && !item.classList.contains('opacity-50')) { // Only if not disabled
handleContextMenuAction(item.dataset.action);
}
}
contextMenu.classList.add('hidden');
}
function handleContextMenuAction(action) {
// console.log("Context menu action:", action);
if (action === 'delete' && selectedNodeElement) {
if (confirm(`Are you sure you want to delete node "${nodes.find(n=>n.id === selectedNodeElement.id)?.properties?.name || selectedNodeElement.id}"?`)) {
deleteNode(selectedNodeElement);
}
}
// Handle copy, paste, duplicate later
closeContextMenu(); // Close after action
}
function handleKeyDown(e) {
// Don't interfere with text input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
return;
}
// Delete selected node
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeElement) {
e.preventDefault(); // Prevent browser back navigation on backspace
if (confirm(`Are you sure you want to delete node "${nodes.find(n=>n.id === selectedNodeElement.id)?.properties?.name || selectedNodeElement.id}"?`)) {
deleteNode(selectedNodeElement);
}
}
// Add other shortcuts later (Ctrl+C, Ctrl+V etc.)
}
function updateTooltipPosition(e) {
tooltip.style.left = `${e.clientX + 15}px`;
tooltip.style.top = `${e.clientY + 15}px`;
const element = document.elementFromPoint(e.clientX, e.clientY);
let tooltipText = null;
if (element) {
if (element.classList.contains('connector')) {
tooltipText = `${element.dataset.type} connector`;
} else if (element.closest('.node-action-btn')) {
tooltipText = element.closest('.node-action-btn').title;
} else if (element.closest('.palette-item')) {
tooltipText = element.closest('.palette-item').title || `Add ${element.closest('.palette-item').querySelector('span')?.textContent} node`;
} else if (element.closest('button')) {
tooltipText = element.closest('button').title;
}
// Add more tooltip targets as needed
}
if (tooltipText) {
tooltip.textContent = tooltipText;
tooltip.classList.remove('hidden');
} else {
tooltip.classList.add('hidden');
}
}
// --- Tooltip Handling ---
function handleCanvasMouseMove(e) {
// Update tooltip position only, dragging handled by document listener
updateTooltipPosition(e);
}
// --- START THE APP ---
initializeApp();
});
</script>
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=LukasBe/visual-agentic-editor" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body>
</html>