diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index a15dd91498..c8bf560a20 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -393,7 +393,10 @@ 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': + 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 @@ -450,6 +453,8 @@ class CreateImageForm(BaseModel): n: int = 1 steps: int | None = None negative_prompt: str | None = None + seed: int | None = None + extra_params: dict | None = None GenerateImageForm = CreateImageForm # Alias for backward compatibility @@ -744,6 +749,12 @@ async def image_generations( if form_data.negative_prompt is not None: data['negative_prompt'] = form_data.negative_prompt + if form_data.seed is not None: + data['seed'] = form_data.seed + + if form_data.extra_params: + data['extra_params'] = form_data.extra_params + form_data = ComfyUICreateImageForm( **{ 'workflow': ComfyUIWorkflow( @@ -764,6 +775,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 +853,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 @@ -847,6 +868,9 @@ class EditImageForm(BaseModel): n: int | None = None negative_prompt: str | None = None background: str | None = None + seed: int | None = None + steps: int | None = None + extra_params: dict | None = None @router.post('/edit') @@ -1130,6 +1154,18 @@ async def image_edits( **({'n': form_data.n} if form_data.n else {}), } + if form_data.negative_prompt is not None: + data['negative_prompt'] = form_data.negative_prompt + + if form_data.seed is not None: + data['seed'] = form_data.seed + + if form_data.steps is not None: + data['steps'] = form_data.steps + + if form_data.extra_params: + data['extra_params'] = form_data.extra_params + form_data = ComfyUIEditImageForm( **{ 'workflow': ComfyUIWorkflow( @@ -1150,6 +1186,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..af2582bb68 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', @@ -142,48 +152,95 @@ class ComfyUICreateImageForm(BaseModel): steps: Optional[int] = None seed: Optional[int] = None + extra_params: Optional[dict] = None 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 '' + + 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 + + elif '::' in node_type: + for node_id in node.node_ids: + if node_id not in workflow: + continue + if node_key in ('ckpt_name', 'unet_name'): + workflow[node_id]['inputs'][node_key] = model + elif node_key in ('text', 'prompt', 'positive'): + 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: + workflow[node_id]['inputs'][node_key] = node.value + elif hasattr(payload, 'extra_params') and payload.extra_params and node_key in payload.extra_params: + workflow[node_id]['inputs'][node_key] = payload.extra_params[node_key] + 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): @@ -219,12 +276,14 @@ class ComfyUIEditImageForm(BaseModel): image: str | list[str] prompt: str + negative_prompt: Optional[str] = None width: Optional[int] = None height: Optional[int] = None n: Optional[int] = None steps: Optional[int] = None seed: Optional[int] = None + extra_params: Optional[dict] = None async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, 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 88e595571b..74e8a4b6d6 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -38,67 +38,227 @@ '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: '' + + let workflowNodesConfig: { type: string; key: string; node_ids: string; class_type: string }[] = + []; + let lastKnownWorkflowString: string | null = null; + + 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; } - ]; + + 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; + + if (valType !== 'string' && valType !== 'number' && valType !== 'boolean') continue; + + discoveredPrimitiveCount++; + + const entryKey = `${nodeId}::${node.class_type}::${inputKey}`; + const semanticType = `${node.class_type}::${inputKey}`; + + 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)) { + const ambiguousKeys = new Set(['text', 'prompt', 'positive']); + const autoAssign = !ambiguousKeys.has(inputKey); + + nodeGroups.set(entryKey, { + type: semanticType, + key: inputKey, + node_ids: autoAssign ? [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; + } + + 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; + } + + const parseWorkflowAndUpdateNodes = (showToast = false, isNewImport = false) => { + const wfString: string = config.COMFYUI_WORKFLOW ?? ''; + + 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: '' + + let editWorkflowNodesConfig: { + type: string; + key: string; + node_ids: string; + class_type: string; + }[] = []; + let lastKnownEditWorkflowString: string | null = null; + + 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 ?? []); + 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; + + const entryKey = `${nodeId}::${node.class_type}::${inputKey}`; + const semanticType = `${node.class_type}::${inputKey}`; + + if (!nodeGroups.has(entryKey)) { + const ambiguousKeys = new Set(['text', 'prompt', 'positive']); + const autoAssign = !ambiguousKeys.has(inputKey); + + nodeGroups.set(entryKey, { + type: semanticType, + key: inputKey, + node_ids: autoAssign ? [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) => { @@ -182,15 +342,11 @@ 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) { @@ -199,15 +355,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(); @@ -243,19 +395,9 @@ } catch (e) { console.error(e); } + 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( @@ -266,6 +408,7 @@ } catch (e) { console.error(e); } + parseEditWorkflowAndUpdateNodes(false, false); } config.IMAGES_OPENAI_API_PARAMS = @@ -277,18 +420,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(',') - }; - }); } }); @@ -552,13 +683,13 @@ 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; + parseWorkflowAndUpdateNodes(true, true); + (e.target as HTMLInputElement).value = ''; }; - reader.readAsText(file); }} /> @@ -605,9 +736,10 @@ lang="json" onChange={(e) => { config.COMFYUI_WORKFLOW = e; + parseWorkflowAndUpdateNodes(false, false); }} onSave={() => { - console.log('Saved'); + parseWorkflowAndUpdateNodes(true, false); }} />