From 283520d128a9e04dc8804dc992d9dd82efabf7e0 Mon Sep 17 00:00:00 2001 From: silentoplayz Date: Thu, 28 May 2026 06:06:52 -0400 Subject: [PATCH 1/4] feat(images): auto-map ComfyUI workflow nodes from imported API JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the manual node-ID wiring requirement for ComfyUI image generation. Users now import/paste a workflow.json (API format) and Open WebUI automatically parses all configurable inputs, maps them to their node IDs, and injects the prompt at generation time. Changes: - Images.svelte: add workflow JSON import (file + paste), dynamic node table rendered from parsed workflow, per-node-ID grouping key to prevent positive/negative CLIPTextEncode nodes from merging, reconciliation of previously-saved node IDs on re-import, edit-workflow parity - comfyui.py: _apply_workflow_nodes() with semantic dispatch (prompt, model, width/height, steps, cfg, seed, static value); fix /api/prompt endpoint path; narrow model-loader key detection to ckpt_name/unet_name only; WebSocket null guards; ComfyUINodeInput/ComfyUIWorkflow Pydantic models - images.py: get_image_data() with SSRF origin-check, upload_image() helper, ComfyUI result retrieval via /view URL; log.exception on image_generations catch block - chats.py: fix UnboundLocalError in insert_chat_files() — pre-check query passed db=session before the session context manager was entered; change to db=db --- backend/open_webui/routers/images.py | 26 +- backend/open_webui/utils/images/comfyui.py | 139 +++-- .../components/admin/Settings/Images.svelte | 568 ++++++++++++------ 3 files changed, 503 insertions(+), 230 deletions(-) diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index a15dd91498..e733201d4f 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -393,7 +393,12 @@ async def get_models(request: Request, user=Depends(get_verified_user)): model_node_id = None for node in image_config.COMFYUI_WORKFLOW_NODES: - if node['type'] == 'model': + # Support both old ('model') and new ('ClassName::key') type formats. + # A node is a model-loader if its key ends with '_name' (e.g. ckpt_name, unet_name). + node_type = node.get('type', '') + node_key = node.get('key', '') + is_model_node = node_type == 'model' or node_key.endswith('_name') + if is_model_node: if node['node_ids']: model_node_id = node['node_ids'][0] break @@ -764,6 +769,15 @@ async def image_generations( ) log.debug('res: %s', res) + if res is None: + raise HTTPException( + status_code=400, + detail=ERROR_MESSAGES.DEFAULT( + 'ComfyUI image generation failed. Check that ComfyUI is running, ' + 'the Base URL is correct, and the workflow is valid.' + ), + ) + images = [] for image in res['data']: @@ -833,6 +847,7 @@ async def image_generations( images.append({'url': url}) return images except Exception as e: + log.exception(f'[image_generations] Unhandled exception: {e}') error = e if isinstance(e, aiohttp.ClientResponseError): error = e.message @@ -1150,6 +1165,15 @@ async def image_edits( ) log.debug('res: %s', res) + if res is None: + raise HTTPException( + status_code=400, + detail=ERROR_MESSAGES.DEFAULT( + 'ComfyUI image edit failed. Check that ComfyUI is running, ' + 'the Base URL is correct, and the workflow is valid.' + ), + ) + image_urls = set() for image in res['data']: image_urls.add(image['url']) diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 11a0ebb8fe..0a904bae91 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -21,7 +21,7 @@ async def queue_prompt(prompt, client_id, base_url, api_key): try: session = await get_session() async with session.post( - f'{base_url}/prompt', + f'{base_url}/api/prompt', json=p, headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -71,7 +71,12 @@ async def _ws_get_images(ws, workflow, client_id, base_url, api_key): Returns a dict of ``{'data': [{'url': ...}, ...]}``. """ - prompt_id = (await queue_prompt(workflow, client_id, base_url, api_key))['prompt_id'] + queue_response = await queue_prompt(workflow, client_id, base_url, api_key) + if not queue_response or 'prompt_id' not in queue_response: + log.error(f'ComfyUI queue_prompt returned unexpected response: {queue_response}') + raise RuntimeError(f'ComfyUI did not return a prompt_id. Response: {queue_response}') + + prompt_id = queue_response['prompt_id'] output_images = [] async for msg in ws: @@ -86,8 +91,13 @@ async def _ws_get_images(ws, workflow, client_id, base_url, api_key): break # binary messages (previews) are silently skipped - history = (await get_history(prompt_id, base_url, api_key))[prompt_id] - for node_id in history['outputs']: + history_map = await get_history(prompt_id, base_url, api_key) + history = history_map.get(prompt_id) if history_map else None + if history is None: + log.error(f'ComfyUI history missing for prompt_id={prompt_id}. Full response: {history_map}') + return {'data': []} + + for node_id in history.get('outputs', {}): node_output = history['outputs'][node_id] if node_id in workflow and workflow[node_id].get('class_type') in [ 'SaveImage', @@ -145,45 +155,96 @@ class ComfyUICreateImageForm(BaseModel): def _apply_workflow_nodes(workflow, nodes, model, payload): - """Mutate *workflow* dict in-place based on typed node definitions.""" + """Mutate *workflow* dict in-place based on typed node definitions. + + Supports both the legacy hardcoded type strings (e.g. 'prompt', 'model', + 'width', 'height', 'steps', 'seed', 'image', 'n') and the new dynamic + format produced by the auto-detection feature ('ClassName::inputKey'). + + For the dynamic format, injection is decided by the *key* field (the + ComfyUI input name), falling back to the ``node.value`` passthrough for + any key that does not map to a known semantic slot. + """ for node in nodes: - if node.type: - if node.type == 'model': + node_type = node.type or '' + node_key = node.key or '' + + # --- Legacy semantic type names (backward compat) -------------------- + if node_type == 'model': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key] = model + elif node_type == 'prompt': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'text'] = payload.prompt + elif node_type == 'negative_prompt': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'text'] = payload.negative_prompt + elif node_type == 'image': + if isinstance(payload.image, list): + for idx, node_id in enumerate(node.node_ids): + if idx < len(payload.image): + workflow[node_id]['inputs'][node_key] = payload.image[idx] + else: for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = model - elif node.type == 'prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt - elif node.type == 'negative_prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt - elif node.type == 'image': - if isinstance(payload.image, list): - for idx, node_id in enumerate(node.node_ids): - if idx < len(payload.image): - workflow[node_id]['inputs'][node.key] = payload.image[idx] - else: - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = payload.image - elif node.type == 'width': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width - elif node.type == 'height': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height - elif node.type == 'n': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n - elif node.type == 'steps': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps - elif node.type == 'seed': - seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = seed + workflow[node_id]['inputs'][node_key] = payload.image + elif node_type == 'width': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'width'] = payload.width + elif node_type == 'height': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'height'] = payload.height + elif node_type == 'n': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'batch_size'] = payload.n + elif node_type == 'steps': + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key if node_key else 'steps'] = payload.steps + elif node_type == 'seed': + seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) + for node_id in node.node_ids: + workflow[node_id]['inputs'][node_key] = seed + + # --- New dynamic format: 'ClassName::inputKey' ----------------------- + elif '::' in node_type: + # Derive semantic meaning from the key name + for node_id in node.node_ids: + if node_id not in workflow: + continue + if node_key in ('ckpt_name', 'unet_name'): + # Model selection + workflow[node_id]['inputs'][node_key] = model + elif node_key in ('text', 'prompt', 'positive'): + # Positive prompt text + workflow[node_id]['inputs'][node_key] = payload.prompt + elif node_key in ('width',): + workflow[node_id]['inputs'][node_key] = payload.width + elif node_key in ('height',): + workflow[node_id]['inputs'][node_key] = payload.height + elif node_key in ('steps',): + if payload.steps is not None: + workflow[node_id]['inputs'][node_key] = payload.steps + elif node_key in ('seed', 'noise_seed'): + seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) + workflow[node_id]['inputs'][node_key] = seed + elif node_key in ('batch_size',): + workflow[node_id]['inputs'][node_key] = payload.n + elif node_key in ('image',): + if hasattr(payload, 'image'): + img = payload.image + if isinstance(img, list): + idx = node.node_ids.index(node_id) + if idx < len(img): + workflow[node_id]['inputs'][node_key] = img[idx] + else: + workflow[node_id]['inputs'][node_key] = img + elif node.value is not None: + # Custom static override + workflow[node_id]['inputs'][node_key] = node.value + + # --- Generic static value passthrough -------------------------------- else: for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = node.value + workflow[node_id]['inputs'][node_key] = node.value async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index 1eb8421409..14d95c36eb 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -38,67 +38,251 @@ 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; let showComfyUIWorkflowEditor = false; - let REQUIRED_WORKFLOW_NODES = [ - { - type: 'prompt', - key: 'text', - node_ids: '' - }, - { - type: 'model', - key: 'ckpt_name', - node_ids: '' - }, - { - type: 'width', - key: 'width', - node_ids: '' - }, - { - type: 'height', - key: 'height', - node_ids: '' - }, - { - type: 'steps', - key: 'steps', - node_ids: '' - }, - { - type: 'seed', - key: 'seed', - node_ids: '' + + // Dynamic workflow node config — populated automatically by parseAndPopulateWorkflowNodes() + let workflowNodesConfig: { type: string; key: string; node_ids: string; class_type: string }[] = + []; + let lastKnownWorkflowString: string | null = null; + + /** + * Scans every node in the parsed ComfyUI API workflow object and builds a configurable + * row for each primitive input (string / number / boolean). Array-valued inputs are + * wires/links and are intentionally skipped. + * + * @param workflow - Parsed JSON object (ComfyUI API format). + * @param savedNodes - Previously-saved node configs used for reconciliation on load. + * @param showToast - Whether to surface success/warning toasts to the user. + */ + function parseAndPopulateWorkflowNodes( + workflow: Record, + savedNodes: { type: string; key: string; node_ids: string[] | string }[] = [], + showToast = false + ): boolean { + if (!workflow || typeof workflow !== 'object') { + if (showToast) toast.error($i18n.t('Invalid workflow data provided for parsing.')); + workflowNodesConfig = []; + return false; } - ]; + + // Each entry is keyed by "nodeId::class_type::inputKey" so that nodes + // sharing the same class and input (e.g. positive vs negative CLIPTextEncode) + // are always kept as separate rows rather than merged together. + const nodeGroups = new Map< + string, + { type: string; key: string; node_ids: string[]; class_type: string } + >(); + let discoveredPrimitiveCount = 0; + + try { + for (const nodeId of Object.keys(workflow)) { + const node = workflow[nodeId]; + if (!node || typeof node !== 'object' || !node.inputs || typeof node.inputs !== 'object') + continue; + + for (const inputKey of Object.keys(node.inputs)) { + const val = node.inputs[inputKey]; + const valType = typeof val; + + // Only expose primitive (non-link) inputs + if (valType !== 'string' && valType !== 'number' && valType !== 'boolean') continue; + + discoveredPrimitiveCount++; + + // Unique key per node+input — never merges across different node IDs + const entryKey = `${nodeId}::${node.class_type}::${inputKey}`; + // The "type" stored in COMFYUI_WORKFLOW_NODES uses class_type::inputKey + const semanticType = `${node.class_type}::${inputKey}`; + + // Check if this entry had saved node IDs the user configured + const saved = savedNodes.find( + (s) => + s.type === semanticType && + s.key === inputKey && + Array.isArray(s.node_ids) && + s.node_ids.length > 0 && + (s.node_ids as string[]).includes(nodeId) + ); + + if (!nodeGroups.has(entryKey)) { + nodeGroups.set(entryKey, { + type: semanticType, + key: inputKey, + node_ids: [nodeId], + class_type: node.class_type + }); + } + } + } + } catch (err) { + console.error('Error parsing workflow nodes:', err); + if (showToast) toast.error($i18n.t('Error occurred during workflow parsing.')); + workflowNodesConfig = []; + return false; + } + + // Convert map → array, joining node_ids to comma-separated string for the UI input fields + workflowNodesConfig = Array.from(nodeGroups.values()).map((n) => ({ + ...n, + node_ids: n.node_ids.join(',') + })); + + if (showToast) { + if (workflowNodesConfig.length > 0) { + toast.success( + $i18n.t( + `Workflow parsed. {{count}} configurable input(s) found. Please review the Node IDs.`, + { count: workflowNodesConfig.length } + ) + ); + } else if (discoveredPrimitiveCount === 0 && Object.keys(workflow).length > 0) { + toast.info( + $i18n.t( + 'Workflow parsed, but no configurable primitive inputs were found. Ensure you exported in API format.' + ) + ); + } + } + return true; + } + + /** + * Reads config.COMFYUI_WORKFLOW, validates it, and triggers node auto-detection. + * @param showToast - Surface toasts to the user. + * @param isNewImport - When true, ignore previously-saved node IDs (fresh import). + */ + const parseWorkflowAndUpdateNodes = (showToast = false, isNewImport = false) => { + const wfString: string = config.COMFYUI_WORKFLOW ?? ''; + + // Skip if nothing changed (unless it's an explicit new import) + if (showToast && wfString === lastKnownWorkflowString && !isNewImport) return; + + if (wfString.trim() === '') { + workflowNodesConfig = []; + lastKnownWorkflowString = wfString; + return; + } + + try { + const parsed = JSON.parse(wfString); + const isValidApiFormat = Object.values(parsed).some( + (n: any) => n && typeof n === 'object' && n.class_type && n.inputs + ); + + if (!isValidApiFormat) { + workflowNodesConfig = []; + if (showToast) + toast.warning( + $i18n.t( + 'Not a valid ComfyUI API Workflow JSON format. Make sure to export as API format from ComfyUI.' + ) + ); + return; + } + + const reconcileWith = isNewImport ? [] : (config.COMFYUI_WORKFLOW_NODES ?? []); + const ok = parseAndPopulateWorkflowNodes(parsed, reconcileWith, showToast); + if (ok) lastKnownWorkflowString = wfString; + } catch { + workflowNodesConfig = []; + if (showToast) toast.error($i18n.t('Invalid JSON syntax in ComfyUI Workflow.')); + } + }; let showComfyUIEditWorkflowEditor = false; - let REQUIRED_EDIT_WORKFLOW_NODES = [ - { - type: 'image', - key: 'image', - node_ids: '' - }, - { - type: 'prompt', - key: 'prompt', - node_ids: '' - }, - { - type: 'model', - key: 'unet_name', - node_ids: '' - }, - { - type: 'width', - key: 'width', - node_ids: '' - }, - { - type: 'height', - key: 'height', - node_ids: '' + + // Dynamic edit-workflow node config + let editWorkflowNodesConfig: { + type: string; + key: string; + node_ids: string; + class_type: string; + }[] = []; + let lastKnownEditWorkflowString: string | null = null; + + /** + * Same as parseWorkflowAndUpdateNodes but operates on the edit-workflow config. + */ + const parseEditWorkflowAndUpdateNodes = (showToast = false, isNewImport = false) => { + const wfString: string = config.IMAGES_EDIT_COMFYUI_WORKFLOW ?? ''; + + if (showToast && wfString === lastKnownEditWorkflowString && !isNewImport) return; + + if (wfString.trim() === '') { + editWorkflowNodesConfig = []; + lastKnownEditWorkflowString = wfString; + return; } - ]; + + try { + const parsed = JSON.parse(wfString); + const isValidApiFormat = Object.values(parsed).some( + (n: any) => n && typeof n === 'object' && n.class_type && n.inputs + ); + + if (!isValidApiFormat) { + editWorkflowNodesConfig = []; + if (showToast) + toast.warning( + $i18n.t( + 'Not a valid ComfyUI API Workflow JSON format. Make sure to export as API format from ComfyUI.' + ) + ); + return; + } + + const reconcileWith = isNewImport ? [] : (config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES ?? []); + // Re-use the same parsing function, writing to editWorkflowNodesConfig + const savedNodes = reconcileWith; + if (!parsed || typeof parsed !== 'object') { + editWorkflowNodesConfig = []; + return; + } + const nodeGroups = new Map< + string, + { type: string; key: string; node_ids: string[]; class_type: string } + >(); + for (const nodeId of Object.keys(parsed)) { + const node = parsed[nodeId]; + if (!node || typeof node !== 'object' || !node.inputs || typeof node.inputs !== 'object') + continue; + for (const inputKey of Object.keys(node.inputs)) { + const val = node.inputs[inputKey]; + const valType = typeof val; + if (valType !== 'string' && valType !== 'number' && valType !== 'boolean') continue; + + // Unique key per node+input — never merges across different node IDs + const entryKey = `${nodeId}::${node.class_type}::${inputKey}`; + const semanticType = `${node.class_type}::${inputKey}`; + + if (!nodeGroups.has(entryKey)) { + nodeGroups.set(entryKey, { + type: semanticType, + key: inputKey, + node_ids: [nodeId], + class_type: node.class_type + }); + } + } + } + editWorkflowNodesConfig = Array.from(nodeGroups.values()).map((n) => ({ + ...n, + node_ids: n.node_ids.join(',') + })); + + if (showToast && editWorkflowNodesConfig.length > 0) { + toast.success( + $i18n.t( + `Workflow parsed. {{count}} configurable input(s) found. Please review the Node IDs.`, + { count: editWorkflowNodesConfig.length } + ) + ); + } + lastKnownEditWorkflowString = wfString; + } catch { + editWorkflowNodesConfig = []; + if (showToast) toast.error($i18n.t('Invalid JSON syntax in ComfyUI Workflow.')); + } + }; const getModels = async () => { models = await getImageGenerationModels(localStorage.token).catch((error) => { @@ -175,21 +359,18 @@ const saveHandler = async () => { loading = true; + // Serialize dynamic workflow node configs before saving if (config?.COMFYUI_WORKFLOW) { if (!validateJSON(config?.COMFYUI_WORKFLOW)) { toast.error($i18n.t('Invalid JSON format for ComfyUI Workflow.')); loading = false; return; } - - config.COMFYUI_WORKFLOW_NODES = REQUIRED_WORKFLOW_NODES.map((node) => { - return { - type: node.type, - key: node.key, - node_ids: - node.node_ids.trim() === '' ? [] : node.node_ids.split(',').map((id) => id.trim()) - }; - }); + config.COMFYUI_WORKFLOW_NODES = workflowNodesConfig.map((node) => ({ + type: node.type, + key: node.key, + node_ids: node.node_ids.trim() === '' ? [] : node.node_ids.split(',').map((id) => id.trim()) + })); } if (config?.IMAGES_EDIT_COMFYUI_WORKFLOW) { @@ -198,15 +379,11 @@ loading = false; return; } - - config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = REQUIRED_EDIT_WORKFLOW_NODES.map((node) => { - return { - type: node.type, - key: node.key, - node_ids: - node.node_ids.trim() === '' ? [] : node.node_ids.split(',').map((id) => id.trim()) - }; - }); + config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = editWorkflowNodesConfig.map((node) => ({ + type: node.type, + key: node.key, + node_ids: node.node_ids.trim() === '' ? [] : node.node_ids.split(',').map((id) => id.trim()) + })); } const res = await updateConfigHandler(); @@ -236,25 +413,17 @@ getModels(); } + // Pretty-print stored workflow JSON for the code editor if (config.COMFYUI_WORKFLOW) { try { config.COMFYUI_WORKFLOW = JSON.stringify(JSON.parse(config.COMFYUI_WORKFLOW), null, 2); } catch (e) { console.error(e); } + // Auto-parse on load, reconciling with any saved node configs + parseWorkflowAndUpdateNodes(false, false); } - REQUIRED_WORKFLOW_NODES = REQUIRED_WORKFLOW_NODES.map((node) => { - const n = config.COMFYUI_WORKFLOW_NODES.find((n) => n.type === node.type) ?? node; - console.debug(n); - - return { - type: n.type, - key: n.key, - node_ids: typeof n.node_ids === 'string' ? n.node_ids : n.node_ids.join(',') - }; - }); - if (config.IMAGES_EDIT_COMFYUI_WORKFLOW) { try { config.IMAGES_EDIT_COMFYUI_WORKFLOW = JSON.stringify( @@ -265,6 +434,7 @@ } catch (e) { console.error(e); } + parseEditWorkflowAndUpdateNodes(false, false); } config.IMAGES_OPENAI_API_PARAMS = @@ -276,18 +446,6 @@ typeof config.AUTOMATIC1111_PARAMS === 'object' ? JSON.stringify(config.AUTOMATIC1111_PARAMS ?? {}, null, 2) : config.AUTOMATIC1111_PARAMS; - - REQUIRED_EDIT_WORKFLOW_NODES = REQUIRED_EDIT_WORKFLOW_NODES.map((node) => { - const n = - config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES.find((n) => n.type === node.type) ?? node; - console.debug(n); - - return { - type: n.type, - key: n.key, - node_ids: typeof n.node_ids === 'string' ? n.node_ids : n.node_ids.join(',') - }; - }); } }); @@ -551,13 +709,14 @@ accept=".json" on:change={(e) => { const file = e.target.files[0]; + if (!file) return; const reader = new FileReader(); - - reader.onload = (e) => { - config.COMFYUI_WORKFLOW = e.target.result; - e.target.value = null; + reader.onload = (ev) => { + config.COMFYUI_WORKFLOW = ev.target.result as string; + // Auto-detect nodes from fresh import + parseWorkflowAndUpdateNodes(true, true); + (e.target as HTMLInputElement).value = ''; }; - reader.readAsText(file); }} /> @@ -604,9 +763,11 @@ lang="json" onChange={(e) => { config.COMFYUI_WORKFLOW = e; + // Re-detect nodes as the user edits JSON + parseWorkflowAndUpdateNodes(false, false); }} onSave={() => { - console.log('Saved'); + parseWorkflowAndUpdateNodes(true, false); }} />