import * as Blockly from 'blockly'; import { blocks } from './blocks/text'; import { forBlock } from './generators/python'; import { pythonGenerator } from 'blockly/python'; import { chatGenerator, forBlock as chatForBlock } from './generators/chat'; import { save, load } from './serialization'; import { toolbox } from './toolbox'; import '@blockly/toolbox-search'; import DarkTheme from '@blockly/theme-dark'; import './index.css'; // Determine the correct base path when running behind HF proxy (/proxy/... or /spaces/...) const getBasePath = () => { const p = window.location.pathname || ""; if (p.startsWith("/proxy/")) { const parts = p.split("/").filter(Boolean); // proxy/owner/space[/...] return "/" + parts.slice(0, 3).join("/"); } if (p.startsWith("/spaces/")) { const parts = p.split("/").filter(Boolean); // spaces/owner/space[/...] return "/" + parts.slice(0, 3).join("/"); } return ""; }; const basePath = getBasePath(); // Session ID Handling function getOrCreateSessionId() { const STORAGE_KEY = "mcp_blockly_session_id"; let sessionId = window.localStorage.getItem(STORAGE_KEY); if (!sessionId) { if (window.crypto && window.crypto.randomUUID) { sessionId = window.crypto.randomUUID(); } else { sessionId = "sess_" + Math.random().toString(36).substring(2) + Date.now().toString(36); } window.localStorage.setItem(STORAGE_KEY, sessionId); } return sessionId; } const sessionId = getOrCreateSessionId(); window.sessionId = sessionId; console.log("[SESSION] Using sessionId:", sessionId); // Share session id with other mounted apps (e.g., Gradio tester) via cookie const sameSite = window.location.protocol === "https:" ? "None" : "Lax"; const secure = window.location.protocol === "https:" ? "; Secure" : ""; const maxAge = 60 * 60 * 24 * 7; // one week document.cookie = `mcp_blockly_session_id=${sessionId}; path=/; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`; // Ensure embedded Gradio iframes receive the session id even when third-party cookies are blocked function attachSessionToIframes() { const frames = [ document.getElementById("gradioTestFrame"), document.getElementById("gradioChatFrame"), ]; frames.forEach((frame) => { if (!frame) return; const baseSrc = frame.dataset.baseSrc || frame.getAttribute("src") || ""; if (!baseSrc) return; // Prefix proxy base when iframe src is absolute-from-root const resolvedBaseSrc = baseSrc.startsWith("/") ? `${basePath}${baseSrc}` : baseSrc; const joiner = baseSrc.includes("?") ? "&" : "?"; frame.src = `${resolvedBaseSrc}${joiner}session_id=${encodeURIComponent(sessionId)}&__theme=dark`; }); } attachSessionToIframes(); // Register the blocks and generator with Blockly Blockly.common.defineBlocks(blocks); Object.assign(pythonGenerator.forBlock, forBlock); // Register chat generator blocks Object.assign(chatGenerator.forBlock, chatForBlock); // Set up UI elements and inject Blockly const blocklyDiv = document.getElementById('blocklyDiv'); // Inject Blockly with theme + renderer const ws = Blockly.inject(blocklyDiv, { toolbox, grid: { spacing: 35, length: 3, colour: '#ccc', snap: false }, disable: false, collapse: false, zoom: { controls: true, wheel: true, startScale: 1.0, maxScale: 3, minScale: 0.3, scaleSpeed: 1.2, pinch: true }, renderer: 'zelos', theme: DarkTheme, }); window.workspace = ws; Blockly.ContextMenuItems.registerCommentOptions(); const newButton = document.querySelector('#newButton'); newButton.addEventListener("click", () => { ws.clear() // Create the MCP block const mcpBlock = ws.newBlock('create_mcp'); mcpBlock.initSvg(); mcpBlock.setDeletable(false); mcpBlock.setMovable(true); // Allow moving but not deleting // Position it in a reasonable spot mcpBlock.moveBy(50, 50); mcpBlock.render(); }); loadButton.addEventListener("click", () => { const input = document.createElement('input'); let fileContent; input.type = 'file'; input.accept = '.txt'; // Specify the file types you want to accept input.onchange = function (event) { const file = event.target.files[0]; const reader = new FileReader(); reader.onload = function (event) { fileContent = JSON.parse(event.target.result); // Parse directly without decoding Blockly.serialization.workspaces.load(fileContent, ws); }; reader.readAsText(file); }; input.click(); }); saveButton.addEventListener("click", () => { const state = Blockly.serialization.workspaces.save(ws); const stateString = JSON.stringify(state); var filename = "mcpBlockly_project.txt"; var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(stateString)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); }); // Download Code button const downloadCodeButton = document.querySelector('#downloadCodeButton'); downloadCodeButton.addEventListener("click", () => { // Get the current generated code const codeEl = document.querySelector('#generatedCode code'); const code = codeEl ? codeEl.textContent : ''; if (!code) { alert('No code to download'); return; } var filename = "app.py"; var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(code)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); }); // Settings button and Keys Modal const settingsButton = document.querySelector('#settingsButton'); const apiKeyModal = document.querySelector('#apiKeyModal'); // const apiKeyInput = document.querySelector('#apiKeyInput'); // TEMP: No OpenAI key input field const hfKeyInput = document.querySelector('#hfKeyInput'); const saveApiKeyButton = document.querySelector('#saveApiKey'); const cancelApiKeyButton = document.querySelector('#cancelApiKey'); const OPENAI_KEY_STORAGE = "mcp_blockly_openai_key"; const HF_KEY_STORAGE = "mcp_blockly_hf_key"; const loadStoredKeys = () => { const storedOpenAI = window.localStorage.getItem(OPENAI_KEY_STORAGE) || ""; const storedHF = window.localStorage.getItem(HF_KEY_STORAGE) || ""; // apiKeyInput.value = storedOpenAI; // TEMP: No OpenAI key input field if (hfKeyInput) { hfKeyInput.value = storedHF; } }; settingsButton.addEventListener("click", () => { if (hfKeyInput) { loadStoredKeys(); } else { console.warn("[Settings] HF key input not found in DOM"); } apiKeyModal.style.display = 'flex'; }); saveApiKeyButton.addEventListener("click", () => { // const apiKey = apiKeyInput.value.trim(); // TEMP: No OpenAI key input field const apiKey = ""; // TEMP: Using free API key, OpenAI field disabled const hfKey = hfKeyInput?.value.trim() || ""; // Validate OpenAI key format if provided if (apiKey && (!apiKey.startsWith("sk-") || apiKey.length < 40)) { alert("Invalid OpenAI API key format. Please enter a valid OpenAI API key (starts with 'sk-')."); return; } // Validate Hugging Face key format if provided if (hfKey && (!hfKey.startsWith("hf_") || hfKey.length < 20)) { alert("Invalid Hugging Face API key format. Please enter a valid Hugging Face API key (starts with 'hf_')."); return; } // Save API keys locally window.localStorage.setItem(OPENAI_KEY_STORAGE, apiKey); window.localStorage.setItem(HF_KEY_STORAGE, hfKey); // Share keys with backend via cookies (per-request, not stored server-side) const cookieOpts = "path=/; SameSite=None; Secure"; if (apiKey) { document.cookie = `mcp_openai_key=${encodeURIComponent(apiKey)}; ${cookieOpts}`; } else { document.cookie = `mcp_openai_key=; Max-Age=0; ${cookieOpts}`; } if (hfKey) { document.cookie = `mcp_hf_key=${encodeURIComponent(hfKey)}; ${cookieOpts}`; } else { document.cookie = `mcp_hf_key=; Max-Age=0; ${cookieOpts}`; } alert('API keys saved successfully'); apiKeyModal.style.display = 'none'; }); cancelApiKeyButton.addEventListener("click", () => { apiKeyModal.style.display = 'none'; }); // Welcome Modal Setup const welcomeModal = document.querySelector('#welcomeModal'); // TEMPORARY FREE API KEY - welcomeApiKeyInput removed from DOM // const welcomeApiKeyInput = document.querySelector('#welcomeApiKeyInput'); const welcomeHfKeyInput = document.querySelector('#welcomeHfKeyInput'); const saveWelcomeApiKeyButton = document.querySelector('#saveWelcomeApiKey'); const skipTutorialButton = document.querySelector('#skipTutorialButton'); const dontShowWelcomeAgainCheckbox = document.querySelector('#dontShowWelcomeAgain'); const WELCOME_DISMISSED_COOKIE = "mcp_blockly_welcome_dismissed"; const getCookieValue = (name) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return null; }; const loadWelcomeStoredKeys = () => { // TEMPORARY FREE API KEY // const storedOpenAI = window.localStorage.getItem(OPENAI_KEY_STORAGE) || ""; const storedHF = window.localStorage.getItem(HF_KEY_STORAGE) || ""; // TEMPORARY FREE API KEY - welcomeApiKeyInput removed from DOM // welcomeApiKeyInput.value = storedOpenAI; welcomeHfKeyInput.value = storedHF; }; const showWelcomeModal = () => { loadWelcomeStoredKeys(); dontShowWelcomeAgainCheckbox.checked = false; welcomeModal.style.display = 'flex'; }; const hideWelcomeModal = () => { welcomeModal.style.display = 'none'; if (dontShowWelcomeAgainCheckbox.checked) { const maxAge = 60 * 60 * 24 * 365; // one year const sameSite = window.location.protocol === "https:" ? "None" : "Lax"; const secure = window.location.protocol === "https:" ? "; Secure" : ""; document.cookie = `${WELCOME_DISMISSED_COOKIE}=true; path=/; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`; } }; saveWelcomeApiKeyButton.addEventListener("click", () => { // TEMPORARY FREE API KEY // const apiKey = welcomeApiKeyInput.value.trim(); const apiKey = ""; // TEMPORARY FREE API KEY - using free API const hfKey = welcomeHfKeyInput.value.trim(); // TEMPORARY FREE API KEY // // Validate OpenAI key format if provided // if (apiKey && (!apiKey.startsWith("sk-") || apiKey.length < 40)) { // alert("Invalid OpenAI API key format. Please enter a valid OpenAI API key (starts with 'sk-')."); // return; // } // Validate Hugging Face key format if provided if (hfKey && (!hfKey.startsWith("hf_") || hfKey.length < 20)) { alert("Invalid Hugging Face API key format. Please enter a valid Hugging Face API key (starts with 'hf_')."); return; } // Save API keys locally window.localStorage.setItem(OPENAI_KEY_STORAGE, apiKey); window.localStorage.setItem(HF_KEY_STORAGE, hfKey); // Share keys with backend via cookies (per-request, not stored server-side) const cookieOpts = "path=/; SameSite=None; Secure"; // TEMPORARY FREE API KEY // if (apiKey) { // document.cookie = `mcp_openai_key=${encodeURIComponent(apiKey)}; ${cookieOpts}`; // } else { // document.cookie = `mcp_openai_key=; Max-Age=0; ${cookieOpts}`; // } document.cookie = `mcp_openai_key=; Max-Age=0; ${cookieOpts}`; // TEMPORARY FREE API KEY - clear free API cookie if (hfKey) { document.cookie = `mcp_hf_key=${encodeURIComponent(hfKey)}; ${cookieOpts}`; } else { document.cookie = `mcp_hf_key=; Max-Age=0; ${cookieOpts}`; } hideWelcomeModal(); // Trigger the tutorial flow tutorialEnabled = true; completedTutorialStepIndex = -1; examplesJustFlashed = false; checkAndFlashExamplesButton(); }); skipTutorialButton.addEventListener("click", () => { tutorialEnabled = false; hideWelcomeModal(); }); // Show welcome modal on first visit const welcomeDismissed = getCookieValue(WELCOME_DISMISSED_COOKIE); console.log('[Welcome] Cookie value:', welcomeDismissed); if (!welcomeDismissed) { // Delay showing welcome to ensure page is fully loaded setTimeout(() => { console.log('[Welcome] Showing welcome modal'); showWelcomeModal(); }, 100); } else { console.log('[Welcome] Welcome modal dismissed, skipping'); } const weatherText = '{"workspaceComments":[{"height":80,"width":477,"id":"XI5[EHp-Ow+kinXf6n5y","x":51.0743994140625,"y":-53.56000305175782,"text":"Gets temperature of location with a latitude and a longitude."}],"blocks":{"languageVersion":0,"blocks":[{"type":"create_mcp","id":")N.HEG1x]Z/,k#TeWr,S","x":50,"y":50,"deletable":false,"extraState":{"inputCount":2,"inputNames":["latitude","longitude"],"inputTypes":["float","float"],"outputCount":1,"outputNames":["output0"],"outputTypes":["float"],"toolCount":0},"inputs":{"X0":{"block":{"type":"input_reference_latitude","id":"]3mj!y}qfRt+!okheU7L","deletable":false,"extraState":{"ownerBlockId":")N.HEG1x]Z/,k#TeWr,S"},"fields":{"VARNAME":"latitude"}}},"X1":{"block":{"type":"input_reference_longitude","id":"Do/{HFNGSd.!;POiKS?D","deletable":false,"extraState":{"ownerBlockId":")N.HEG1x]Z/,k#TeWr,S"},"fields":{"VARNAME":"longitude"}}},"R0":{"block":{"type":"cast_as","id":"vKz#fsrWMW(M9*:3Pv;2","fields":{"TYPE":"float"},"inputs":{"VALUE":{"block":{"type":"in_json","id":"R|j?_8s^H{l0;UZ-oQt3","inputs":{"NAME":{"block":{"type":"text","id":"@Z+@U^@8c0gQYj}La`PY","fields":{"TEXT":"temperature_2m"}}},"JSON":{"block":{"type":"in_json","id":"X=M,R1@7bRjJVZIPi[qD","inputs":{"NAME":{"block":{"type":"text","id":"OMr~`#kG$3@k`YPDHbzH","fields":{"TEXT":"current"}}},"JSON":{"block":{"type":"call_api","id":"^(.vyM.yni08S~c1EBm=","fields":{"METHOD":"GET"},"inputs":{"URL":{"shadow":{"type":"text","id":"}.T;_U_OsRS)B_y09p % { ","fields":{"TEXT":""}},"block":{"type":"text_replace","id":"OwH9uERJPTGQG!UER#ch","inputs":{"FROM":{"shadow":{"type":"text","id":"ya05#^ 7 % UbUeXX#eDSmH","fields":{"TEXT":"{latitude}"}},"block":{"type":"text","id":"6CX#+wo9^x+vZ`LRt5ms","fields":{"TEXT":"{latitude}"}}},"TO":{"shadow":{"type":"text","id":": _ZloQuh9c-MNf-U]!k5","fields":{"TEXT":""}},"block":{"type":"cast_as","id":"qXXp2GSF;@+ssDvHN={+","fields":{"TYPE":"str"},"inputs":{"VALUE":{"block":{"type":"input_reference_latitude","id":"?%@)3sErZ)}=#4ags#gu","extraState":{"ownerBlockId":")N.HEG1x]Z/,k#TeWr,S"},"fields":{"VARNAME":"latitude"}}}}}},"TEXT":{"shadow":{"type":"text","id":"w@zsP)m6:WjkUp,ln3$x","fields":{"TEXT":""}},"block":{"type":"text_replace","id":"ImNPsvzD7r^+1MJ%IirV","inputs":{"FROM":{"shadow":{"type":"text","id":"%o(3rro?WLIFpmE0#MMM","fields":{"TEXT":"{longitude}"}},"block":{"type":"text","id":"`p!s8dQ7e~?0JvofyB-{","fields":{"TEXT":"{longitude}"}}},"TO":{"shadow":{"type":"text","id":"Zpql-%oJ_sdSi | r |* er | ","fields":{"TEXT":""}},"block":{"type":"cast_as","id":"T5r7Y,;]kq2wClH)JUf8","fields":{"TYPE":"str"},"inputs":{"VALUE":{"block":{"type":"input_reference_longitude","id":"WUgiJP$X + zY#f$5nhnTX","extraState":{"ownerBlockId":") N.HEG1x]Z /, k#TeWr, S"},"fields":{"VARNAME":"longitude"}}}}}},"TEXT":{"shadow":{"type":"text","id":", (vw$o_s7P = b4P; 8]}yj","fields":{"TEXT":"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m,wind_speed_10m"}}}}}}}}}}}}}}}}}}}}}}}]}}'; let examplesJustFlashed = false; let selectedExample = null; // Track which example was selected let tutorialEnabled = false; // Track if tutorial is enabled // Tutorial popup management const tutorialStepOrder = ['examples', 'refresh', 'test', 'aiAssistant', 'send']; let completedTutorialStepIndex = -1; const getTutorialStepIndex = (step) => tutorialStepOrder.indexOf(step); const hasCompletedStepOrBeyond = (step) => { const idx = getTutorialStepIndex(step); return idx !== -1 && completedTutorialStepIndex >= idx; }; const markTutorialStepComplete = (step) => { const idx = getTutorialStepIndex(step); if (idx > completedTutorialStepIndex) { completedTutorialStepIndex = idx; } }; let currentTutorialStep = null; const tutorialPopup = document.getElementById('tutorialPopup'); const tutorialTitle = document.getElementById('tutorialTitle'); const tutorialBody = document.getElementById('tutorialBody'); const tutorialSkipButton = document.getElementById('tutorialSkipButton'); console.log('[TUTORIAL] Popup elements:', { tutorialPopup, tutorialTitle, tutorialBody, tutorialSkipButton }); const tutorialContent = { examples: { title: '1/5 Try an Example', body: 'Click on one of the example workflows to get started quickly.' }, refresh: { title: '2/5 Refresh Info', body: 'Click the Refresh button to update the inputs and outputs of your MCP server.' }, test: { title: '3/5 Run Your Test', get body() { if (selectedExample === 'weather') { return 'Enter a latitude (-90 to 90) and longitude (-180 to 80), then press Test to get the current temperature at that location!'; } else if (selectedExample === 'fact') { return 'Enter a claim you want to verify, then press Test to check if it\'s true!'; } return 'Enter some values in your MCP inputs, and press the Test button to see its output.'; } }, aiAssistant: { title: '4/5 Get AI Guidance', body: 'After your output is generated, switch to the AI Assistant tab to chat with the integrated agent.' }, send: { title: '5/5 Send a Message', body: 'Try using one of the three suggested prompts (click on one of the buttons), or write one of your own!' } }; const showTutorialPopup = (step, targetElement) => { console.log('[TUTORIAL] showTutorialPopup called for step:', step, 'element:', targetElement); if (!tutorialContent[step]) { console.log('[TUTORIAL] Content not found for step:', step); return; } currentTutorialStep = step; tutorialTitle.textContent = tutorialContent[step].title; // Handle both regular string body and getter function const bodyContent = typeof tutorialContent[step].body === 'function' ? tutorialContent[step].body() : tutorialContent[step].body; tutorialBody.textContent = bodyContent; tutorialPopup.style.display = 'block'; console.log('[TUTORIAL] Popup display set to block'); // Position the popup next to the target element if (targetElement) { setTimeout(() => { const rect = targetElement.getBoundingClientRect(); const popupWidth = 280; const popupHeight = 150; // Approximate height const spacing = 25; console.log('[TUTORIAL] Target element rect:', rect, 'window:', window.innerWidth, 'x', window.innerHeight); let top = rect.top; let left = rect.right + spacing; // Priority 1: Try to place to the right if (left + popupWidth > window.innerWidth) { // Priority 2: Try to place below left = rect.left; top = rect.bottom + spacing; if (top + popupHeight > window.innerHeight) { // Priority 3: Place to the left left = rect.left - popupWidth - spacing; top = rect.top; } } console.log('[TUTORIAL] Setting popup position to top:', top, 'left:', left); tutorialPopup.style.top = `${top}px`; tutorialPopup.style.left = `${left}px`; }, 50); } }; const hideTutorialPopup = () => { tutorialPopup.style.display = 'none'; currentTutorialStep = null; }; tutorialSkipButton.addEventListener('click', () => { // Stop the tutorial and remove all flashing indicators (including inside iframes) tutorialEnabled = false; examplesJustFlashed = false; document.querySelectorAll('.flash-button').forEach(el => el.classList.remove('flash-button')); const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { const doc = iframe.contentDocument || iframe.contentWindow.document; doc?.querySelectorAll('.flash-button').forEach(el => el.classList.remove('flash-button')); } catch (e) { // Ignore cross-origin frames } } hideTutorialPopup(); }); const loadSendButtonFlash = () => { if (!tutorialEnabled) return; // Find send button in the chat iframe (it's the button with just an SVG icon) let sendBtn = null; let iframeDoc = null; console.log('[TUTORIAL] Looking for send button in iframes'); const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { iframeDoc = iframe.contentDocument || iframe.contentWindow.document; if (iframeDoc) { // Look for send button - it's the one with SVG viewBox="0 0 22 24" const buttons = iframeDoc.querySelectorAll('button'); console.log('[TUTORIAL] Found', buttons.length, 'buttons in iframe'); for (let btn of buttons) { // Look for the button with the send SVG (has viewBox="0 0 22 24") if (btn.innerHTML.includes('viewBox="0 0 22 24"')) { console.log('[TUTORIAL] Found send button with SVG!'); sendBtn = btn; break; } } if (sendBtn) break; } } catch (e) { // Cannot access iframe, continue console.log('[TUTORIAL] Cannot access iframe:', e); } } console.log('[TUTORIAL] Send button found?', !!sendBtn); if (sendBtn && iframeDoc) { // Inject CSS if needed const styleId = 'send-flash-style'; if (!iframeDoc.getElementById(styleId)) { const style = iframeDoc.createElement('style'); style.id = styleId; style.textContent = ` @keyframes pulseOutline { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 1); } 75% { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } } .flash-button { animation: pulseOutline 1s ease-out infinite !important; } `; iframeDoc.head.appendChild(style); } sendBtn.classList.add('flash-button'); showTutorialPopup('send', sendBtn); // Watch for when the send button gets removed/replaced (which happens when message is sent) const observer = new MutationObserver((mutations) => { console.log('[TUTORIAL] Send button DOM changed - message was sent'); hideTutorialPopup(); observer.disconnect(); }); // Observe the button's parent for changes (including removal/replacement) if (sendBtn.parentElement) { observer.observe(sendBtn.parentElement, { childList: true, subtree: true }); } } }; const loadAiAssistantTabFlash = () => { if (!tutorialEnabled || hasCompletedStepOrBeyond('aiAssistant')) return; const aiTab = document.querySelector('[data-tab="aichat"]'); if (aiTab) { aiTab.classList.add('flash-button'); showTutorialPopup('aiAssistant', aiTab); // Remove flash on click const removeFlash = () => { aiTab.classList.remove('flash-button'); aiTab.removeEventListener('click', removeFlash); markTutorialStepComplete('aiAssistant'); hideTutorialPopup(); // After AI Assistant tab is clicked, flash the send button setTimeout(() => { loadSendButtonFlash(); }, 100); }; aiTab.addEventListener('click', removeFlash); } }; const loadExamplesButtonFlash = () => { if (!tutorialEnabled || hasCompletedStepOrBeyond('examples')) return; const examplesButton = Array.from(document.querySelectorAll('.menuButton')).find(btn => btn.textContent === 'Examples'); console.log('[TUTORIAL] Examples button found:', examplesButton); if (examplesButton) { examplesButton.classList.add('flash-button'); examplesJustFlashed = true; console.log('[TUTORIAL] Showing examples popup'); showTutorialPopup('examples', examplesButton); } else { console.log('[TUTORIAL] Examples button NOT found'); } }; const loadTestButtonFlash = (testBtn, iframeDoc) => { if (!tutorialEnabled || hasCompletedStepOrBeyond('test') || !testBtn || !iframeDoc) return; if (testBtn && iframeDoc) { testBtn.classList.add('flash-button'); showTutorialPopup('test', testBtn); // Remove flash on click const removeFlash = () => { testBtn.classList.remove('flash-button'); testBtn.removeEventListener('click', removeFlash); markTutorialStepComplete('test'); hideTutorialPopup(); // Proceed to the next step loadAiAssistantTabFlash(); }; testBtn.addEventListener('click', removeFlash); } }; const loadRefreshButtonFlash = () => { if (!tutorialEnabled || hasCompletedStepOrBeyond('refresh')) return; let refreshBtn = null; let testBtn = null; let iframeDoc = null; // Check iframes for the Refresh and Test buttons const iframes = document.querySelectorAll('iframe'); for (const iframe of iframes) { try { iframeDoc = iframe.contentDocument || iframe.contentWindow.document; if (iframeDoc) { const iframeButtons = iframeDoc.querySelectorAll('button'); // Try to find Refresh and Test buttons in this iframe for (let btn of iframeButtons) { if (btn.textContent.includes('Refresh') || btn.innerHTML.includes('Refresh')) { refreshBtn = btn; } if (btn.textContent.includes('Test') && btn.classList.contains('secondary')) { testBtn = btn; } if (refreshBtn && testBtn) break; } if (refreshBtn && testBtn) break; } } catch (e) { // Cannot access iframe, continue } } if (refreshBtn && iframeDoc) { // Inject CSS into the iframe if needed const styleId = 'refresh-flash-style'; if (!iframeDoc.getElementById(styleId)) { const style = iframeDoc.createElement('style'); style.id = styleId; style.textContent = ` @keyframes pulseOutline { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 1); } 75% { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } } .flash-button { animation: pulseOutline 1s ease-out infinite !important; } `; iframeDoc.head.appendChild(style); } refreshBtn.classList.add('flash-button'); showTutorialPopup('refresh', refreshBtn); // Remove flash on click const removeFlash = () => { refreshBtn.classList.remove('flash-button'); refreshBtn.removeEventListener('click', removeFlash); markTutorialStepComplete('refresh'); hideTutorialPopup(); // After Refresh is clicked, flash the Test button if (testBtn) { loadTestButtonFlash(testBtn, iframeDoc); } }; refreshBtn.addEventListener('click', removeFlash); } else { // Retry after a delay if button not found yet setTimeout(loadRefreshButtonFlash, 1000); } }; const checkAndFlashExamplesButton = () => { console.log('[TUTORIAL] checkAndFlashExamplesButton called, tutorialEnabled:', tutorialEnabled, 'examplesJustFlashed:', examplesJustFlashed); if (!tutorialEnabled) { console.log('[TUTORIAL] Tutorial disabled, skipping'); return; } if (hasCompletedStepOrBeyond('examples')) { console.log('[TUTORIAL] Examples step already completed, skipping'); return; } if (!examplesJustFlashed) { // Flash the examples button immediately console.log('[TUTORIAL] About to call loadExamplesButtonFlash'); loadExamplesButtonFlash(); } }; weatherButton.addEventListener("click", () => { try { selectedExample = 'weather'; const fileContent = JSON.parse(weatherText); Blockly.serialization.workspaces.load(fileContent, ws); const shouldHandleTutorial = tutorialEnabled && !hasCompletedStepOrBeyond('examples'); if (shouldHandleTutorial) { // Example loaded - stop flashing Examples button and move to Refresh const examplesButton = Array.from(document.querySelectorAll('.menuButton')).find(btn => btn.textContent === 'Examples'); if (examplesButton) { examplesButton.classList.remove('flash-button'); } hideTutorialPopup(); examplesJustFlashed = false; markTutorialStepComplete('examples'); // Flash refresh button when example is selected loadRefreshButtonFlash(); } } catch (error) { console.error("Error loading weather.txt contents:", error); } }); const factText = "{\"workspaceComments\":[{\"height\":66,\"width\":575,\"id\":\"x/Z2E2Oid(4||-pQ)h*;\",\"x\":51.00000000000023,\"y\":-35.76388082917071,\"text\":\"A fact checker that uses a searching LLM to verify the validity of a claim.\"}],\"blocks\":{\"languageVersion\":0,\"blocks\":[{\"type\":\"create_mcp\",\"id\":\"yScKJD/XLhk)D}qn2TW:\",\"x\":50,\"y\":50,\"deletable\":false,\"extraState\":{\"inputCount\":1,\"inputNames\":[\"prompt\"],\"inputTypes\":[\"string\"],\"outputCount\":1,\"outputNames\":[\"result\"],\"outputTypes\":[\"string\"],\"toolCount\":0},\"inputs\":{\"X0\":{\"block\":{\"type\":\"input_reference_prompt\",\"id\":\"-r%M-[oX1]?RxxF_V(V@\",\"deletable\":false,\"extraState\":{\"ownerBlockId\":\"yScKJD/XLhk)D}qn2TW:\"},\"fields\":{\"VARNAME\":\"prompt\"}}},\"R0\":{\"block\":{\"type\":\"llm_call\",\"id\":\"m/*D8ZBx;QZlUN*aw15U\",\"fields\":{\"MODEL\":\"gpt-4o-search-preview-2025-03-11\"},\"inputs\":{\"PROMPT\":{\"block\":{\"type\":\"text_join\",\"id\":\"e@#`RVKXpIZ9__%zUK]`\",\"extraState\":{\"itemCount\":3},\"inputs\":{\"ADD0\":{\"block\":{\"type\":\"text\",\"id\":\"M3QD})k`FXiizaF,gA{9\",\"fields\":{\"TEXT\":\"Verify whether the following claim: \\\"\"}}},\"ADD1\":{\"block\":{\"type\":\"input_reference_prompt\",\"id\":\"B4.LNZ0es`RFM0Xi@SL:\",\"extraState\":{\"ownerBlockId\":\"yScKJD/XLhk)D}qn2TW:\"},\"fields\":{\"VARNAME\":\"prompt\"}}},\"ADD2\":{\"block\":{\"type\":\"text\",\"id\":\"Ng!fFR+xTMdmgWZv6Oh{\",\"fields\":{\"TEXT\":\"\\\" is true or not. Return one of the following values: \\\"True\\\", \\\"Unsure\\\", \\\"False\\\", and nothing else. You may not say anything but one of these answers no matter what.\"}}}}}}}}}}}]}}" factButton.addEventListener("click", () => { try { selectedExample = 'fact'; const fileContent = JSON.parse(factText); Blockly.serialization.workspaces.load(fileContent, ws); const shouldHandleTutorial = tutorialEnabled && !hasCompletedStepOrBeyond('examples'); if (shouldHandleTutorial) { // Example loaded - stop flashing Examples button and move to Refresh const examplesButton = Array.from(document.querySelectorAll('.menuButton')).find(btn => btn.textContent === 'Examples'); if (examplesButton) { examplesButton.classList.remove('flash-button'); } hideTutorialPopup(); examplesJustFlashed = false; markTutorialStepComplete('examples'); // Flash refresh button when example is selected loadRefreshButtonFlash(); } } catch (error) { console.error("Error loading weather.txt contents:", error); } }); undoButton.addEventListener("click", () => { ws.undo(false); }); redoButton.addEventListener("click", () => { ws.undo(true); }); cleanWorkspace.addEventListener("click", () => { ws.cleanUp(); }); function parseAndCreateBlock(spec, shouldPosition = false, placementType = null, placementBlockID = null) { // Match block_name(inputs(...)) with proper parenthesis matching const blockMatch = spec.match(/^(\w+)\s*\((.*)$/s); if (!blockMatch) { throw new Error(`Invalid block specification format: ${spec}`); } const blockType = blockMatch[1]; let content = blockMatch[2].trim(); // We need to find the matching closing parenthesis for blockType( // Count from the beginning and find where the matching ) is let parenCount = 1; // We already have the opening ( let matchIndex = -1; let inQuotes = false; let quoteChar = ''; for (let i = 0; i < content.length; i++) { const char = content[i]; // Handle quotes if ((char === '"' || char === "'") && (i === 0 || content[i - 1] !== '\\')) { if (!inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar) { inQuotes = false; } } // Only count parens outside quotes if (!inQuotes) { if (char === '(') parenCount++; else if (char === ')') { parenCount--; if (parenCount === 0) { matchIndex = i; break; } } } } // Extract content up to the matching closing paren if (matchIndex >= 0) { content = content.slice(0, matchIndex).trim(); } else { // Fallback: remove last paren if present if (content.endsWith(')')) { content = content.slice(0, -1).trim(); } } console.log('[SSE CREATE] Parsing block:', blockType, 'with content:', content); // Check if this has inputs() wrapper let inputsContent = content; if (content.startsWith('inputs(')) { // Find the matching closing parenthesis for inputs( parenCount = 1; matchIndex = -1; inQuotes = false; quoteChar = ''; for (let i = 7; i < content.length; i++) { // Start after 'inputs(' const char = content[i]; // Handle quotes if ((char === '"' || char === "'") && (i === 0 || content[i - 1] !== '\\')) { if (!inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar) { inQuotes = false; } } // Only count parens outside quotes if (!inQuotes) { if (char === '(') parenCount++; else if (char === ')') { parenCount--; if (parenCount === 0) { matchIndex = i; break; } } } } // Extract content between inputs( and its matching ) if (matchIndex >= 0) { inputsContent = content.slice(7, matchIndex); } else { // Fallback: remove inputs( and last ) if (content.endsWith(')')) { inputsContent = content.slice(7, -1); } else { inputsContent = content.slice(7); } } } console.log('[SSE CREATE] inputsContent to parse:', inputsContent); // VALIDATION: Check if trying to place a value block under a statement block // Value blocks have an output connection but no previous connection if (placementType === 'under') { // Check if this block type is a value block by temporarily creating it const testBlock = ws.newBlock(blockType); const isValueBlock = testBlock.outputConnection && !testBlock.previousConnection; testBlock.dispose(true); // Remove the test block if (isValueBlock) { throw new Error(`Cannot place value block '${blockType}' under a statement block. Value blocks must be nested inside inputs of other blocks or placed in MCP outputs using type: "input". Try creating a variable and assigning the value to that.`); } } // Create the block const newBlock = ws.newBlock(blockType); if (inputsContent) { // Parse the inputs content console.log('[SSE CREATE] About to call parseInputs with:', inputsContent); const inputs = parseInputs(inputsContent); console.log('[SSE CREATE] Parsed inputs:', inputs); // Special handling for make_json block if (blockType === 'make_json') { // Count FIELD entries to determine how many fields we need let fieldCount = 0; const fieldValues = {}; const keyValues = {}; for (const [key, value] of Object.entries(inputs)) { const fieldMatch = key.match(/^FIELD(\d+)$/); const keyMatch = key.match(/^KEY(\d+)$/); if (fieldMatch) { const index = parseInt(fieldMatch[1]); fieldCount = Math.max(fieldCount, index + 1); fieldValues[index] = value; } else if (keyMatch) { const index = parseInt(keyMatch[1]); keyValues[index] = value; } } // Set up the mutator state if (fieldCount > 0) { newBlock.fieldCount_ = fieldCount; newBlock.fieldKeys_ = []; // Create the inputs through the mutator for (let i = 0; i < fieldCount; i++) { const keyValue = keyValues[i]; const key = (typeof keyValue === 'string' && !keyValue.match(/^\w+\s*\(inputs\(/)) ? keyValue.replace(/^["']|["']$/g, '') : `key${i}`; newBlock.fieldKeys_[i] = key; // Create the input const input = newBlock.appendValueInput('FIELD' + i); const field = new Blockly.FieldTextInput(key); field.setValidator((newValue) => { newBlock.fieldKeys_[i] = newValue || `key${i}`; return newValue; }); input.appendField(field, 'KEY' + i); input.appendField(':'); } // Now connect the field values for (let i = 0; i < fieldCount; i++) { const value = fieldValues[i]; if (value && typeof value === 'string' && value.match(/^\w+\s*\(inputs\(/)) { // This is a nested block, create it recursively const childBlock = parseAndCreateBlock(value); // Connect the child block to the FIELD input const input = newBlock.getInput('FIELD' + i); if (input && input.connection && childBlock.outputConnection) { childBlock.outputConnection.connect(input.connection); } } } } } else if (blockType === 'text_join') { // Special handling for text_join block (and similar blocks with ADD0, ADD1, ADD2...) // Count ADD entries to determine how many items we need let addCount = 0; const addValues = {}; for (const [key, value] of Object.entries(inputs)) { const addMatch = key.match(/^ADD(\d+)$/); if (addMatch) { const index = parseInt(addMatch[1]); addCount = Math.max(addCount, index + 1); addValues[index] = value; } } console.log('[SSE CREATE] text_join detected with', addCount, 'items'); // Store pending text_join state to apply after initSvg() if (addCount > 0) { newBlock.pendingAddCount_ = addCount; newBlock.pendingAddValues_ = addValues; } } else if (blockType === 'controls_if') { // Special handling for if/else blocks - create condition blocks now and store references const conditionBlocks = {}; const conditionBlockObjects = {}; let hasElse = false; console.log('[SSE CREATE] controls_if inputs:', inputs); // Process condition inputs and store block objects // Blockly uses IF0, IF1, IF2... not IF, IFELSEN0, IFELSEN1 for (const [key, value] of Object.entries(inputs)) { if (key.match(/^IF\d+$/)) { // This is a condition block specification (IF0, IF1, IF2, ...) conditionBlocks[key] = value; if (typeof value === 'string' && value.match(/^\w+\s*\(inputs\(/)) { // Create the condition block now const conditionBlock = parseAndCreateBlock(value); conditionBlockObjects[key] = conditionBlock; console.log('[SSE CREATE] Created condition block for', key); } } else if (key === 'ELSE' && value === true) { // ELSE is a marker with no value (set to true by parseInputs) console.log('[SSE CREATE] Detected ELSE marker'); hasElse = true; } } // Count IFELSE (else-if) blocks: IF1, IF2, IF3... (IF0 is the main if, not an else-if) let elseIfCount = 0; for (const key of Object.keys(conditionBlocks)) { if (key.match(/^IF\d+$/) && key !== 'IF0') { elseIfCount++; } } console.log('[SSE CREATE] controls_if parsed: elseIfCount =', elseIfCount, 'hasElse =', hasElse); // Store condition block OBJECTS for later - we'll connect them after mutator creates inputs newBlock.pendingConditionBlockObjects_ = conditionBlockObjects; newBlock.pendingElseifCount_ = elseIfCount; newBlock.pendingElseCount_ = hasElse ? 1 : 0; console.log('[SSE CREATE] Stored pending condition block objects:', Object.keys(conditionBlockObjects)); // Skip normal input processing for controls_if - we handle conditions after mutator } else if (blockType !== 'controls_if') { // Normal block handling (skip for controls_if which is handled specially) for (const [key, value] of Object.entries(inputs)) { if (typeof value === 'string') { // Check if this is a nested block specification if (value.match(/^\w+\s*\(inputs\(/)) { // This is a nested block, create it recursively const childBlock = parseAndCreateBlock(value); // Connect the child block to the appropriate input const input = newBlock.getInput(key); if (input && input.connection && childBlock.outputConnection) { childBlock.outputConnection.connect(input.connection); } } else { // This is a simple value, set it as a field // Remove quotes if present const cleanValue = value.replace(/^["']|["']$/g, ''); // Try to set as a field value try { newBlock.setFieldValue(cleanValue, key); } catch (e) { console.log(`[SSE CREATE] Could not set field ${key} to ${cleanValue}:`, e); } } } else if (typeof value === 'number') { // Set numeric field value try { newBlock.setFieldValue(value, key); } catch (e) { console.log(`[SSE CREATE] Could not set field ${key} to ${value}:`, e); } } else if (typeof value === 'boolean') { // Set boolean field value try { newBlock.setFieldValue(value ? 'TRUE' : 'FALSE', key); } catch (e) { console.log(`[SSE CREATE] Could not set field ${key} to ${value}:`, e); } } } } } // Initialize the block (renders it) newBlock.initSvg(); // Apply pending controls_if mutations (must be after initSvg) try { console.log('[SSE CREATE] Checking for controls_if mutations: type =', newBlock.type, 'pendingElseifCount_ =', newBlock.pendingElseifCount_, 'pendingConditionBlockObjects_ =', !!newBlock.pendingConditionBlockObjects_); if (newBlock.type === 'controls_if' && (newBlock.pendingElseifCount_ > 0 || newBlock.pendingElseCount_ > 0 || newBlock.pendingConditionBlockObjects_)) { console.log('[SSE CREATE] ENTERING controls_if mutation block'); console.log('[SSE CREATE] Applying controls_if mutation:', { elseifCount: newBlock.pendingElseifCount_, elseCount: newBlock.pendingElseCount_ }); // Use the loadExtraState method if available (Blockly's preferred way) if (typeof newBlock.loadExtraState === 'function') { const state = {}; if (newBlock.pendingElseifCount_ > 0) { state.elseIfCount = newBlock.pendingElseifCount_; } if (newBlock.pendingElseCount_ > 0) { state.hasElse = true; } console.log('[SSE CREATE] Using loadExtraState with:', state); newBlock.loadExtraState(state); console.log('[SSE CREATE] After loadExtraState'); } else { // Fallback: Set the internal state variables and call updateShape_ newBlock.elseifCount_ = newBlock.pendingElseifCount_; newBlock.elseCount_ = newBlock.pendingElseCount_; if (typeof newBlock.updateShape_ === 'function') { console.log('[SSE CREATE] Calling updateShape_ on controls_if'); newBlock.updateShape_(); } } // Now that the mutator has created all the inputs, connect the stored condition block objects console.log('[SSE CREATE] pendingConditionBlockObjects_ exists?', !!newBlock.pendingConditionBlockObjects_); if (newBlock.pendingConditionBlockObjects_) { const conditionBlockObjects = newBlock.pendingConditionBlockObjects_; console.log('[SSE CREATE] Connecting condition blocks:', Object.keys(conditionBlockObjects)); // Connect the IF0 condition if (conditionBlockObjects['IF0']) { const ifBlock = conditionBlockObjects['IF0']; const input = newBlock.getInput('IF0'); console.log('[SSE CREATE] IF0 input exists?', !!input); if (input && input.connection && ifBlock.outputConnection) { ifBlock.outputConnection.connect(input.connection); console.log('[SSE CREATE] Connected IF0 condition'); } else { console.warn('[SSE CREATE] Could not connect IF0 - input:', !!input, 'childConnection:', !!ifBlock.outputConnection); } } // Connect IF1, IF2, IF3... (else-if conditions) console.log('[SSE CREATE] Processing', newBlock.pendingElseifCount_, 'else-if conditions'); for (let i = 1; i <= newBlock.pendingElseifCount_; i++) { const key = 'IF' + i; console.log('[SSE CREATE] Looking for key:', key, 'exists?', !!conditionBlockObjects[key]); if (conditionBlockObjects[key]) { const ifElseBlock = conditionBlockObjects[key]; const input = newBlock.getInput(key); console.log('[SSE CREATE] Input', key, 'exists?', !!input); if (input && input.connection && ifElseBlock.outputConnection) { ifElseBlock.outputConnection.connect(input.connection); console.log('[SSE CREATE] Connected', key, 'condition'); } else { console.warn('[SSE CREATE] Could not connect', key, '- input exists:', !!input, 'has connection:', input ? !!input.connection : false, 'childHasOutput:', !!ifElseBlock.outputConnection); } } } } else { console.warn('[SSE CREATE] No pendingConditionBlockObjects_ found'); } // Verify the ELSE input was created if (newBlock.pendingElseCount_ > 0) { const elseInput = newBlock.getInput('ELSE'); console.log('[SSE CREATE] ELSE input after mutation:', elseInput); if (!elseInput) { console.error('[SSE CREATE] ELSE input was NOT created!'); } } // Re-render after connecting condition blocks newBlock.render(); } } catch (err) { console.error('[SSE CREATE] Error in controls_if mutations:', err); } // Apply pending text_join mutations (must be after initSvg) if (newBlock.type === 'text_join' && newBlock.pendingAddCount_ && newBlock.pendingAddCount_ > 0) { console.log('[SSE CREATE] Applying text_join mutation with', newBlock.pendingAddCount_, 'items'); const addCount = newBlock.pendingAddCount_; const addValues = newBlock.pendingAddValues_; // Use loadExtraState if available to set the item count if (typeof newBlock.loadExtraState === 'function') { newBlock.loadExtraState({ itemCount: addCount }); } else { // Fallback: set internal state newBlock.itemCount_ = addCount; } // Now connect the ADD values for (let i = 0; i < addCount; i++) { const value = addValues[i]; if (value && typeof value === 'string' && value.match(/^\w+\s*\(inputs\(/)) { // This is a nested block, create it recursively const childBlock = parseAndCreateBlock(value); // Connect the child block to the ADD input const input = newBlock.getInput('ADD' + i); if (input && input.connection && childBlock.outputConnection) { childBlock.outputConnection.connect(input.connection); console.log('[SSE CREATE] Connected ADD' + i + ' input'); } else { console.warn('[SSE CREATE] Could not connect ADD' + i + ' input'); } } } } // Only position the top-level block if (shouldPosition) { // Find a good position that doesn't overlap existing blocks const existingBlocks = ws.getAllBlocks(); let x = 50; let y = 50; // Simple positioning: stack new blocks vertically if (existingBlocks.length > 0) { const lastBlock = existingBlocks[existingBlocks.length - 1]; const lastPos = lastBlock.getRelativeToSurfaceXY(); y = lastPos.y + lastBlock.height + 20; } newBlock.moveBy(x, y); } // Render the block newBlock.render(); return newBlock; } // Helper function to parse inputs(key: value, key2: value2, ...) function parseInputs(inputStr) { const result = {}; let currentKey = ''; let currentValue = ''; let depth = 0; let inQuotes = false; let quoteChar = ''; let readingKey = true; for (let i = 0; i < inputStr.length; i++) { const char = inputStr[i]; // Handle quotes if ((char === '"' || char === "'") && (i === 0 || inputStr[i - 1] !== '\\')) { if (!inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar) { inQuotes = false; quoteChar = ''; } } // Handle parentheses depth (for nested blocks) if (!inQuotes) { if (char === '(') depth++; else if (char === ')') depth--; } // Handle key-value separation if (char === ':' && depth === 0 && !inQuotes && readingKey) { readingKey = false; currentKey = currentKey.trim(); continue; } // Handle comma separation if (char === ',' && depth === 0 && !inQuotes && !readingKey) { // Store the key-value pair currentValue = currentValue.trim(); // Parse the value if (currentValue.match(/^\w+\s*\(inputs\(/)) { // This is a nested block result[currentKey] = currentValue; } else if (currentValue.match(/^-?\d+(\.\d+)?$/)) { // This is a number result[currentKey] = parseFloat(currentValue); } else if (currentValue === 'true' || currentValue === 'false') { // This is a boolean result[currentKey] = currentValue === 'true'; } else { // This is a string (remove quotes if present) result[currentKey] = currentValue.replace(/^["']|["']$/g, ''); } // Reset for next key-value pair currentKey = ''; currentValue = ''; readingKey = true; continue; } // Accumulate characters if (readingKey) { currentKey += char; } else { currentValue += char; } } // Handle the last key-value pair if (currentKey) { currentKey = currentKey.trim(); // If there's no value, this is a flag/marker (like ELSE) if (!currentValue) { result[currentKey] = true; // Mark it as present } else { currentValue = currentValue.trim(); // Parse the value if (currentValue.match(/^\w+\s*\(inputs\(/)) { // This is a nested block result[currentKey] = currentValue; } else if (currentValue.match(/^-?\d+(\.\d+)?$/)) { // This is a number result[currentKey] = parseFloat(currentValue); } else if (currentValue === 'true' || currentValue === 'false') { // This is a boolean result[currentKey] = currentValue === 'true'; } else { // This is a string (remove quotes if present) result[currentKey] = currentValue.replace(/^["']|["']$/g, ''); } } } return result; } // Set up unified SSE connection for all workspace operations const setupUnifiedStream = () => { const esUrl = `${basePath}/unified_stream?session_id=${sessionId}`; console.log("[SSE] Opening unified_stream", esUrl); const eventSource = new EventSource(esUrl); const processedRequests = new Set(); // Track processed requests eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); // Skip heartbeat messages if (data.heartbeat) return; // Determine request key based on type let requestKey; if (data.type === 'delete') { requestKey = `delete_${data.block_id}`; } else if (data.type === 'create') { requestKey = `create_${data.request_id}`; } else if (data.type === 'variable') { requestKey = `variable_${data.request_id}`; } else if (data.type === 'edit_mcp') { requestKey = `edit_mcp_${data.request_id}`; } else if (data.type === 'replace') { requestKey = `replace_${data.request_id}`; } // Skip if we've already processed this request if (requestKey && processedRequests.has(requestKey)) { console.log('[SSE] Skipping duplicate request:', requestKey); return; } if (requestKey) { processedRequests.add(requestKey); // Clear after 10 seconds to allow retries if needed setTimeout(() => processedRequests.delete(requestKey), 10000); } // Handle edit MCP requests if (data.type === 'edit_mcp' && data.request_id) { console.log('[SSE] Received edit MCP request:', data); let success = false; let error = null; try { // Find the create_mcp block const mcpBlocks = ws.getBlocksByType('create_mcp'); const mcpBlock = mcpBlocks[0]; if (!mcpBlock) { throw new Error('No create_mcp block found in workspace'); } // Disable events to prevent infinite loops Blockly.Events.disable(); try { // Create a container block for the mutator const containerBlock = ws.newBlock('container'); containerBlock.initSvg(); // Build inputs if provided if (data.inputs && Array.isArray(data.inputs)) { let connection = containerBlock.getInput('STACK').connection; for (let idx = 0; idx < data.inputs.length; idx++) { const input = data.inputs[idx]; const itemBlock = ws.newBlock('container_input'); itemBlock.initSvg(); itemBlock.setFieldValue(input.type || 'string', 'TYPE'); itemBlock.setFieldValue(input.name || '', 'NAME'); connection.connect(itemBlock.previousConnection); connection = itemBlock.nextConnection; } } // Build outputs if provided if (data.outputs && Array.isArray(data.outputs)) { let connection2 = containerBlock.getInput('STACK2').connection; for (let idx = 0; idx < data.outputs.length; idx++) { const output = data.outputs[idx]; const itemBlock = ws.newBlock('container_output'); itemBlock.initSvg(); itemBlock.setFieldValue(output.type || 'string', 'TYPE'); itemBlock.setFieldValue(output.name || 'output', 'NAME'); connection2.connect(itemBlock.previousConnection); connection2 = itemBlock.nextConnection; } } // Apply changes using the compose method mcpBlock.compose(containerBlock); // Clean up containerBlock.dispose(); success = true; console.log('[SSE] Successfully edited MCP block'); } finally { Blockly.Events.enable(); } } catch (e) { error = e.toString(); console.error('[SSE] Error editing MCP block:', e); } // Send result back to backend immediately console.log('[SSE] Sending edit MCP result:', { request_id: data.request_id, success, error }); fetch(`${basePath}/request_result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, request_type: 'edit_mcp', request_id: data.request_id, success: success, error: error }) }).then(response => { console.log('[SSE] Edit MCP result sent successfully'); }).catch(err => { console.error('[SSE] Error sending edit MCP result:', err); }); } // Handle replace block requests else if (data.type === 'replace' && data.block_id && data.block_spec && data.request_id) { console.log('[SSE] Received replace request for block:', data.block_id, data.block_spec); let success = false; let error = null; let blockId = null; try { // Get the block to be replaced const blockToReplace = ws.getBlockById(data.block_id); if (!blockToReplace) { throw new Error(`Block ${data.block_id} not found`); } // Store connection info before creating new block const parentBlock = blockToReplace.getParent(); const previousBlock = blockToReplace.getPreviousBlock(); const nextBlock = blockToReplace.getNextBlock(); let parentConnection = null; let inputName = null; // Check if this block is connected to a parent's input if (blockToReplace.outputConnection && blockToReplace.outputConnection.targetConnection) { parentConnection = blockToReplace.outputConnection.targetConnection; } else if (blockToReplace.previousConnection && blockToReplace.previousConnection.targetConnection) { parentConnection = blockToReplace.previousConnection.targetConnection; } // If the block is in an input socket, get that info if (parentBlock) { const inputs = parentBlock.inputList; for (const input of inputs) { if (input.connection && input.connection.targetBlock() === blockToReplace) { inputName = input.name; break; } } } // Preserve only statement/next blocks (the body), not input field values const statementBlocks = {}; const oldInputList = blockToReplace.inputList; for (const input of oldInputList) { // Only preserve STATEMENT type inputs (like BODY, SUBSTACK, etc) - these are the internal structure // Skip VALUE inputs and other types that might contain data values if (input.type === Blockly.NEXT_STATEMENT && input.connection && input.connection.targetBlock()) { const childBlock = input.connection.targetBlock(); statementBlocks[input.name] = { block: childBlock, connection: input.connection }; console.log('[SSE] Found statement input to preserve:', input.name); } } // Create the new block using the shared function (no positioning, no placement type for replace) const newBlock = parseAndCreateBlock(data.block_spec, false, null, null); if (!newBlock) { throw new Error('Failed to create replacement block'); } // Reattach the new block to the parent connection if (parentConnection) { if (newBlock.outputConnection) { parentConnection.connect(newBlock.outputConnection); } else if (newBlock.previousConnection) { parentConnection.connect(newBlock.previousConnection); } } // Reattach next block if it was connected if (nextBlock && newBlock.nextConnection) { newBlock.nextConnection.connect(nextBlock.previousConnection); } // Transfer only statement blocks (body/internal structure) to matching inputs on new block console.log('[SSE] Transferring', Object.keys(statementBlocks).length, 'statement blocks to new block'); for (const [oldInputName, blockInfo] of Object.entries(statementBlocks)) { const newInput = newBlock.getInput(oldInputName); if (newInput && newInput.connection && newInput.type === Blockly.NEXT_STATEMENT) { console.log('[SSE] Transferring statement input:', oldInputName); // Disconnect from old parent blockInfo.block.unplug(false); // false = don't dispose children // Connect to new parent if (newInput.connection.targetBlock()) { // Input already has something connected, skip console.log('[SSE] Input', oldInputName, 'already has children, skipping'); } else { newInput.connection.connect(blockInfo.block.previousConnection); } } else { console.log('[SSE] New block does not have matching STATEMENT input:', oldInputName); // Disconnect the child block from the old parent but leave it orphaned // (it will appear as a separate block in the workspace) blockInfo.block.unplug(false); } } // Dispose only the old block itself, not its children (dispose(false)) blockToReplace.dispose(false); // Render the workspace ws.render(); success = true; blockId = newBlock.id; console.log('[SSE] Successfully replaced block:', data.block_id, 'with:', newBlock.id); } catch (e) { error = e.toString(); console.error('[SSE] Error replacing block:', e); } // Send result back to backend console.log('[SSE] Sending replace block result:', { request_id: data.request_id, success, error, block_id: blockId }); fetch(`${basePath}/request_result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, request_type: 'replace', request_id: data.request_id, success: success, error: error, block_id: blockId }) }).then(response => { console.log('[SSE] Replace block result sent successfully'); }).catch(err => { console.error('[SSE] Error sending replace block result:', err); }); } // Handle deletion requests else if (data.type === 'delete' && data.block_id) { console.log('[SSE] Received deletion request for block:', data.block_id); // Try to delete the block const block = ws.getBlockById(data.block_id); let success = false; let error = null; if (block) { console.log('[SSE] Found block to delete:', block.type, block.id); // Check if it's the main create_mcp block (which shouldn't be deleted) if (block.type === 'create_mcp' && !block.isDeletable()) { error = 'Cannot delete the main create_mcp block'; console.log('[SSE] Block is protected create_mcp'); } else { try { block.dispose(true); success = true; console.log('[SSE] Successfully deleted block:', data.block_id); } catch (e) { error = e.toString(); console.error('[SSE] Error deleting block:', e); } } } else { error = 'Block not found'; console.log('[SSE] Block not found:', data.block_id); } // Send result back to backend immediately console.log('[SSE] Sending deletion result:', { block_id: data.block_id, success, error }); fetch(`${basePath}/request_result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, request_type: 'delete', block_id: data.block_id, success: success, error: error }) }).then(response => { console.log('[SSE] Deletion result sent successfully'); }).catch(err => { console.error('[SSE] Error sending deletion result:', err); }); } // Handle creation requests else if (data.type === 'create' && data.block_spec && data.request_id) { console.log('[SSE] Received creation request:', data.request_id, data.block_spec); let success = false; let error = null; let blockId = null; try { // Create the block and all its nested children const newBlock = parseAndCreateBlock(data.block_spec, true, data.placement_type, data.blockID); if (newBlock) { blockId = newBlock.id; success = true; // Block was created successfully // Handle placement based on placement_type if (data.placement_type === 'input') { // Place into MCP block's output slot // For type: 'input', find the first MCP block and use input_name for the slot const mcpBlock = ws.getBlocksByType('create_mcp')[0]; if (mcpBlock) { let inputSlot = data.input_name; // If slot name is not in R format, look it up by output name if (inputSlot && !inputSlot.match(/^R\d+$/)) { const outputNames = mcpBlock.outputNames_ || []; const outputIndex = outputNames.indexOf(inputSlot); if (outputIndex >= 0) { inputSlot = 'R' + outputIndex; } } const input = mcpBlock.getInput(inputSlot); if (input && input.connection) { console.log('[SSE CREATE] Placing block into MCP output slot:', inputSlot); // Disconnect any existing block const existingBlock = input.connection.targetBlock(); if (existingBlock) { existingBlock.unplug(); } // Connect the new block if (newBlock.outputConnection) { input.connection.connect(newBlock.outputConnection); console.log('[SSE CREATE] Successfully placed block into slot:', inputSlot); } else { error = `Block has no output connection to connect to MCP slot ${inputSlot}`; console.error('[SSE CREATE]', error); } } else { // Try to get all available inputs on the MCP block for debugging const availableInputs = mcpBlock.inputList.map(inp => inp.name).join(', '); error = `Output slot '${inputSlot}' not found. Available inputs: ${availableInputs}`; console.error('[SSE CREATE]', error); } } else { error = `No MCP block found in workspace`; console.error('[SSE CREATE]', error); } } // If placement_type is 'under', attach the new block under the parent else if (data.placement_type === 'under') { const parentBlock = ws.getBlockById(data.blockID); if (parentBlock) { console.log('[SSE CREATE] Attaching to parent block:', data.blockID); // If input_name is specified, try to connect to that specific input first let connected = false; if (data.input_name) { const input = parentBlock.getInput(data.input_name); if (input && input.type === Blockly.NEXT_STATEMENT) { // Check if something is already connected if (input.connection && !input.connection.targetBlock()) { // Connect directly if (newBlock.previousConnection) { input.connection.connect(newBlock.previousConnection); connected = true; console.log('[SSE CREATE] Connected to specified input:', data.input_name); } } else if (input.connection && input.connection.targetBlock()) { // Find the last block in the stack let lastBlock = input.connection.targetBlock(); while (lastBlock.nextConnection && lastBlock.nextConnection.targetBlock()) { lastBlock = lastBlock.nextConnection.targetBlock(); } // Connect to the end of the stack if (lastBlock.nextConnection && newBlock.previousConnection) { lastBlock.nextConnection.connect(newBlock.previousConnection); connected = true; console.log('[SSE CREATE] Connected to end of stack in specified input:', data.input_name); } } } else { error = `Specified input '${data.input_name}' not found or is not a statement input`; console.warn('[SSE CREATE]', error); } } // If not connected via specified input_name, try common statement inputs if (!connected) { const statementInputs = ['BODY', 'DO', 'THEN', 'ELSE', 'STACK']; for (const inputName of statementInputs) { const input = parentBlock.getInput(inputName); if (input && input.type === Blockly.NEXT_STATEMENT) { // Check if something is already connected if (input.connection && !input.connection.targetBlock()) { // Connect directly if (newBlock.previousConnection) { input.connection.connect(newBlock.previousConnection); connected = true; console.log('[SSE CREATE] Connected to input:', inputName); break; } } else if (input.connection && input.connection.targetBlock()) { // Find the last block in the stack let lastBlock = input.connection.targetBlock(); while (lastBlock.nextConnection && lastBlock.nextConnection.targetBlock()) { lastBlock = lastBlock.nextConnection.targetBlock(); } // Connect to the end of the stack if (lastBlock.nextConnection && newBlock.previousConnection) { lastBlock.nextConnection.connect(newBlock.previousConnection); connected = true; console.log('[SSE CREATE] Connected to end of stack in input:', inputName); break; } } } } } // If not connected to statement input, try value inputs if (!connected) { // Try all inputs const inputs = parentBlock.inputList; for (const input of inputs) { if (input.type === Blockly.INPUT_VALUE && input.connection && !input.connection.targetBlock()) { if (newBlock.outputConnection) { input.connection.connect(newBlock.outputConnection); connected = true; console.log('[SSE CREATE] Connected to value input:', input.name); break; } } } } if (!connected) { error = `Could not find suitable connection point on parent block`; console.warn('[SSE CREATE]', error); } } else { error = `Parent block not found: ${data.blockID}`; console.warn('[SSE CREATE]', error); } } if (success) { console.log('[SSE CREATE] Successfully created block with children:', blockId, newBlock.type); } } else { throw new Error(`Failed to create block from specification`); } } catch (e) { error = e.toString(); console.error('[SSE CREATE] Error creating block:', e); } // Send result back to backend immediately console.log('[SSE CREATE] Sending creation result:', { request_id: data.request_id, success, error, block_id: blockId }); fetch(`${basePath}/request_result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, request_type: 'create', request_id: data.request_id, success: success, error: error, block_id: blockId }) }).then(response => { console.log('[SSE CREATE] Creation result sent successfully'); }).catch(err => { console.error('[SSE CREATE] Error sending creation result:', err); }); } // Handle variable creation requests else if (data.type === 'variable' && data.variable_name && data.request_id) { console.log('[SSE] Received variable creation request:', data.request_id, data.variable_name); let success = false; let error = null; let variableId = null; try { // Create the variable using Blockly's variable map const variableName = data.variable_name; // Use the workspace's variable map to create a new variable const variableModel = ws.getVariableMap().createVariable(variableName); if (variableModel) { variableId = variableModel.getId(); success = true; console.log('[SSE] Successfully created variable:', variableName, 'with ID:', variableId); } else { throw new Error('Failed to create variable model'); } } catch (e) { error = e.toString(); console.error('[SSE] Error creating variable:', e); } // Send result back to backend immediately console.log('[SSE] Sending variable creation result:', { request_id: data.request_id, success, error, variable_id: variableId }); fetch(`${basePath}/request_result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, request_type: 'variable', request_id: data.request_id, success: success, error: error, variable_id: variableId }) }).then(response => { console.log('[SSE] Variable creation result sent successfully'); }).catch(err => { console.error('[SSE] Error sending variable creation result:', err); }); } } catch (err) { console.error('[SSE] Error processing message:', err); } }; eventSource.onerror = (error) => { console.error('[SSE] Connection error:', error); // Reconnect after 5 seconds setTimeout(() => { console.log('[SSE] Attempting to reconnect...'); setupUnifiedStream(); }, 5000); }; eventSource.onopen = () => { console.log('[SSE] Connected to unified stream'); }; }; // Start the unified SSE connection setupUnifiedStream(); // Observe any size change to the blockly container const observer = new ResizeObserver(() => { Blockly.svgResize(ws); }); observer.observe(blocklyDiv); const updateCode = () => { // 1) Create isolated temporary workspace const tempWs = new Blockly.Workspace(); // 2) Copy only allowed root blocks into the temp workspace const topBlocks = ws.getTopBlocks(false); for (const block of topBlocks) { if (block.type === 'create_mcp' || block.type === 'func_def') { Blockly.serialization.blocks.append( Blockly.serialization.blocks.save(block), tempWs ); } } // 3) Generate code from the clean workspace let code = pythonGenerator.workspaceToCode(tempWs); // 4) Prepend helper functions collected during generation const defs = pythonGenerator.definitions_ ? Object.values(pythonGenerator.definitions_) : []; if (defs.length) { code = defs.join('\n') + '\n\n' + code; } // Variable map (unchanged) const vars = ws.getVariableMap().getAllVariables(); globalVarString = vars.map(v => `↿ ${v.id} ↾ ${v.name}`).join("\n"); const codeEl = document.querySelector('#generatedCode code'); // Your custom helpers const call = `def llm_call(prompt, model): from openai import OpenAI import os api_key = os.environ.get("OPENAI_API_KEY") if not api_key: return "Error: OpenAI API key not configured. Please set it in your HuggingFace Space env in its settings." client = OpenAI(api_key=api_key) messages = [{"role": "user", "content": prompt}] completion = client.chat.completions.create(model=model, messages=messages) return completion.choices[0].message.content.strip() `; const API = `def call_api(url, method="GET", headers={}): import requests response = requests.request(method, url, headers=headers) data = response.json() return data `; const blocks = ws.getAllBlocks(false); const hasCall = blocks.some(block => block.type === 'llm_call'); const hasAPI = blocks.some(block => block.type === 'call_api'); const hasPrime = code.includes('math_isPrime('); if (hasCall) code = call + code; if (hasAPI) code = API + code; if (hasPrime) { code = code.replace(/math_isPrime\(([^)]*)\)/g, 'isprime($1)'); code = "from sympy import isprime\n\n" + code; } code = "import gradio as gr\nimport ast\n" + code; // Extract input and output counts from the create_mcp block to build Gradio interface const mcpBlocks = ws.getBlocksByType('create_mcp'); if (mcpBlocks.length > 0) { const mcpBlock = mcpBlocks[0]; // Build list of Gradio input components based on input types const gradioInputs = []; if (mcpBlock.inputCount_ && mcpBlock.inputCount_ > 0 && mcpBlock.getInput('X0')) { for (let k = 0; k < mcpBlock.inputCount_; k++) { const type = mcpBlock.inputTypes_[k]; switch (type) { case 'integer': gradioInputs.push('gr.Number()'); break; case 'float': gradioInputs.push('gr.Number()'); break; case 'string': gradioInputs.push('gr.Textbox()'); break; case 'list': gradioInputs.push('gr.Textbox()'); break; case 'boolean': gradioInputs.push('gr.Checkbox()'); break; case 'any': gradioInputs.push('gr.JSON()'); break; default: gradioInputs.push('gr.Textbox()'); } } } // Build list of Gradio output components based on output types const gradioOutputs = []; if (mcpBlock.outputCount_ && mcpBlock.outputCount_ > 0 && mcpBlock.getInput('R0')) { for (let k = 0; k < mcpBlock.outputCount_; k++) { const type = mcpBlock.outputTypes_[k]; switch (type) { case 'integer': gradioOutputs.push('gr.Number()'); break; case 'float': gradioOutputs.push('gr.Number()'); break; case 'string': gradioOutputs.push('gr.Textbox()'); break; case 'list': gradioOutputs.push('gr.Dataframe()'); break; case 'boolean': gradioOutputs.push('gr.Checkbox()'); break; case 'any': gradioOutputs.push('gr.JSON()'); break; default: gradioOutputs.push('gr.Textbox()'); } } } // Append Gradio interface code at the very end code += `\ndemo = gr.Interface( fn=create_mcp, inputs=[${gradioInputs.join(', ')}], outputs=[${gradioOutputs.join(', ')}], ) demo.launch(mcp_server=True) `; } if (codeEl) { codeEl.textContent = code; } fetch(`${basePath}/update_code`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code }), }).catch((err) => { console.error("[Blockly] Error sending Python code:", err); }); }; // Track if chat backend is available let chatBackendAvailable = false; let chatUpdateQueue = []; let chatRetryTimeout = null; // Global variables for chat code and variables let globalChatCode = ''; let globalVarString = ''; // Function to check if chat backend is available const checkChatBackend = async () => { try { const response = await fetch(`${basePath}/update_chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code: globalChatCode, varString: globalVarString }), }); if (response.ok) { chatBackendAvailable = true; console.log("[Blockly] Chat backend is available"); // Process any queued updates processChatUpdateQueue(); return true; } } catch (err) { chatBackendAvailable = false; } return false; }; // Process queued chat updates const processChatUpdateQueue = () => { if (chatBackendAvailable && chatUpdateQueue.length > 0) { const code = chatUpdateQueue.pop(); // Get the latest update chatUpdateQueue = []; // Clear the queue sendChatUpdate(code); } }; // Send chat update with retry logic const sendChatUpdate = async (chatCode, retryCount = 0) => { try { const response = await fetch(`${basePath}/update_chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code: chatCode, varString: globalVarString }), }); if (response.ok) { chatBackendAvailable = true; console.log("[Blockly] Sent updated Chat code to backend"); } else { throw new Error(`Server responded with status ${response.status}`); } } catch (err) { console.warn(`[Blockly] Chat backend not ready (attempt ${retryCount + 1}):`, err.message); chatBackendAvailable = false; // Queue this update for retry if (retryCount < 5) { const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s setTimeout(() => { if (!chatBackendAvailable) { checkChatBackend().then(available => { if (available) { sendChatUpdate(chatCode, retryCount + 1); } else if (retryCount < 4) { sendChatUpdate(chatCode, retryCount + 1); } }); } }, delay); } } }; // Update function for the Chat generator (AI Assistant tab) const updateChatCode = () => { globalChatCode = chatGenerator.workspaceToCode(ws); const codeEl = document.querySelector('#aichatCode code'); // You can add any chat-specific preprocessing here // For example, adding headers or formatting if (codeEl) { codeEl.textContent = globalChatCode; } // If backend is available, send immediately if (chatBackendAvailable) { sendChatUpdate(globalChatCode); } else { // Queue the update and try to establish connection chatUpdateQueue.push(globalChatCode); // Clear any existing retry timeout if (chatRetryTimeout) { clearTimeout(chatRetryTimeout); } // Try to connect to backend checkChatBackend(); // Set up periodic retry chatRetryTimeout = setTimeout(() => { checkChatBackend(); }, 2000); } }; try { load(ws); // After loading, ensure reference blocks are properly connected and tracked setTimeout(() => { const mutatorBlocks = ws.getAllBlocks(false).filter(b => (b.type === 'create_mcp' || b.type === 'func_def') ); for (const block of mutatorBlocks) { // Initialize the reference block map if needed if (!block.inputRefBlocks_) { block.inputRefBlocks_ = new Map(); } // Create reference blocks for each input if they don't exist if (block.inputNames_ && block.inputNames_.length > 0) { for (let i = 0; i < block.inputNames_.length; i++) { const name = block.inputNames_[i]; const input = block.getInput('X' + i); if (input && input.connection) { const connectedBlock = input.connection.targetBlock(); const expectedType = `input_reference_${name}`; // If there's already the correct block connected, just track it if (connectedBlock && connectedBlock.type === expectedType) { connectedBlock._ownerBlockId = block.id; connectedBlock.setDeletable(false); block.inputRefBlocks_.set(name, connectedBlock); } // Only create if input exists AND has no connected block yet else if (!connectedBlock) { // Create the reference block const refBlock = ws.newBlock(expectedType); refBlock.initSvg(); refBlock.setDeletable(false); refBlock._ownerBlockId = block.id; refBlock.render(); // Connect it if (refBlock.outputConnection) { input.connection.connect(refBlock.outputConnection); } // Track it block.inputRefBlocks_.set(name, refBlock); } } } } } ws.render(); }, 100); } catch (e) { console.warn('Workspace load failed, clearing storage:', e); localStorage.clear(); } // Ensure there's always one MCP block in the workspace const existingMcpBlocks = ws.getBlocksByType('create_mcp'); if (existingMcpBlocks.length === 0) { // Create the MCP block const mcpBlock = ws.newBlock('create_mcp'); mcpBlock.initSvg(); mcpBlock.setDeletable(false); mcpBlock.setMovable(true); // Allow moving but not deleting // Position it in a reasonable spot mcpBlock.moveBy(50, 50); mcpBlock.render(); } updateCode(); // Check if chat backend is available before first update checkChatBackend().then(() => { updateChatCode(); }); // Also set up periodic health checks for the chat backend setInterval(() => { if (!chatBackendAvailable) { checkChatBackend(); } }, 5000); // Check every 5 seconds if not connected ws.addChangeListener((e) => { if (e.isUiEvent) return; save(ws); }); ws.addChangeListener((event) => { if (event.isUiEvent) return; if (event.type === Blockly.Events.BLOCK_MOVE) { if (event.oldParentId && !event.newParentId) { const removedBlock = ws.getBlockById(event.blockId); const oldParent = ws.getBlockById(event.oldParentId); if ( removedBlock && oldParent && (removedBlock.type.startsWith('input_reference_') && (oldParent.type === 'create_mcp' || oldParent.type === 'func_def')) ) { // Only duplicate if removed from a mutator input (X0, X1, X2, etc.) // NOT from other inputs like RETURN, BODY, or title input const inputName = event.oldInputName; const isMutatorInput = inputName && /^X\d+$/.test(inputName); if (isMutatorInput) { Blockly.Events.disable(); try { // Create a new block of the same reference type const newRefBlock = ws.newBlock(removedBlock.type); newRefBlock.initSvg(); newRefBlock.setDeletable(false); // This one stays in the MCP block // Mark the new reference block with its owner (same as the parent) newRefBlock._ownerBlockId = oldParent.id; const input = oldParent.getInput(inputName); if (input) { input.connection.connect(newRefBlock.outputConnection); } // Update the parent block's reference tracking if (removedBlock.type.startsWith('input_reference_')) { const varName = removedBlock.type.replace('input_reference_', ''); if (oldParent.inputRefBlocks_) { oldParent.inputRefBlocks_.set(varName, newRefBlock); } } // Make the dragged-out block deletable removedBlock.setDeletable(true); ws.render(); // ensure workspace updates immediately } finally { Blockly.Events.enable(); } } } } } save(ws); }); ws.addChangeListener((e) => { if (e.isUiEvent || e.type == Blockly.Events.FINISHED_LOADING || ws.isDragging()) { return; } updateCode(); updateChatCode(); });