Spaces:
Running
Running
| <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> |