feat(images): auto-map ComfyUI workflow nodes from imported API JSON

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
This commit is contained in:
silentoplayz 2026-05-28 06:06:52 -04:00
parent 29541cbb52
commit 283520d128
3 changed files with 503 additions and 230 deletions

View file

@ -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'])

View file

@ -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):

View file

@ -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<string, any>,
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(',')
};
});
}
});
</script>
@ -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);
}}
/>
<!-- {#if config.COMFYUI_WORKFLOW}
@ -623,51 +784,63 @@
{#if config.COMFYUI_WORKFLOW}
<AdminSettingField
label={$i18n.t('ComfyUI Workflow Nodes')}
description={$i18n.t('Map workflow node inputs used for image generation.')}
description={$i18n.t(
'Node IDs are auto-detected from your workflow. Adjust them if needed.'
)}
>
<div class="flex flex-col gap-1.5 text-xs">
{#each REQUIRED_WORKFLOW_NODES as node}
<div class="flex w-full flex-col">
<div class="shrink-0">
<div class=" capitalize line-clamp-1 w-20 text-gray-400 dark:text-gray-500">
{node.type}{node.type === 'prompt' ? '*' : ''}
</div>
</div>
{#if workflowNodesConfig.length > 0}
<div class="flex items-center gap-1 text-xs text-green-500 dark:text-green-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Auto-detected')}
</div>
<div class="flex mt-0.5 items-center">
<div class="">
<Tooltip content={$i18n.t('Input Key (e.g. text, unet_name, steps)')}>
<input
class="{inputClass} w-24"
placeholder={$i18n.t('Key')}
bind:value={node.key}
required
/>
</Tooltip>
</div>
<div class="px-2 text-gray-400 dark:text-gray-500">:</div>
<div class="w-full">
<Tooltip
content={$i18n.t('Comma separated Node Ids (e.g. 1 or 1,2)')}
placement="top-start"
<div class="mt-1 flex flex-col gap-1.5 text-xs">
{#each workflowNodesConfig as node}
<div class="flex w-full flex-col">
<div class="shrink-0">
<div
class="line-clamp-1 text-gray-400 dark:text-gray-500"
title={node.type}
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
<span class="font-medium">{node.class_type}</span><span
class="opacity-60">::{node.key}</span
>
</div>
</div>
<div class="mt-0.5 flex items-center">
<div class="w-full">
<Tooltip
content={$i18n.t('Comma separated Node Ids (e.g. 1 or 1,2)')}
placement="top-start"
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
</div>
</div>
</div>
</div>
{/each}
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('*Prompt node ID(s) are required for image generation')}
</div>
{/each}
</div>
{:else}
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No configurable inputs detected. Upload a workflow in API format.')}
</div>
{/if}
</AdminSettingField>
{/if}
{:else if config?.IMAGE_GENERATION_ENGINE === 'gemini'}
@ -849,13 +1022,14 @@
accept=".json"
on:change={(e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
config.IMAGES_EDIT_COMFYUI_WORKFLOW = e.target.result;
e.target.value = null;
reader.onload = (ev) => {
config.IMAGES_EDIT_COMFYUI_WORKFLOW = ev.target.result as string;
// Auto-detect nodes from fresh import
parseEditWorkflowAndUpdateNodes(true, true);
(e.target as HTMLInputElement).value = '';
};
reader.readAsText(file);
}}
/>
@ -901,9 +1075,11 @@
lang="json"
onChange={(e) => {
config.IMAGES_EDIT_COMFYUI_WORKFLOW = e;
// Re-detect nodes as the user edits JSON
parseEditWorkflowAndUpdateNodes(false, false);
}}
onSave={() => {
console.log('Saved');
parseEditWorkflowAndUpdateNodes(true, false);
}}
/>
</div>
@ -911,51 +1087,63 @@
{#if config.IMAGES_EDIT_COMFYUI_WORKFLOW}
<AdminSettingField
label={$i18n.t('ComfyUI Workflow Nodes')}
description={$i18n.t('Map workflow node inputs used for image edits.')}
description={$i18n.t(
'Node IDs are auto-detected from your workflow. Adjust them if needed.'
)}
>
<div class="flex flex-col gap-1.5 text-xs">
{#each REQUIRED_EDIT_WORKFLOW_NODES as node}
<div class="flex w-full flex-col">
<div class="shrink-0">
<div class=" capitalize line-clamp-1 w-20 text-gray-400 dark:text-gray-500">
{node.type}{['prompt', 'image'].includes(node.type) ? '*' : ''}
</div>
</div>
{#if editWorkflowNodesConfig.length > 0}
<div class="flex items-center gap-1 text-xs text-green-500 dark:text-green-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Auto-detected')}
</div>
<div class="flex mt-0.5 items-center">
<div class="">
<Tooltip content={$i18n.t('Input Key (e.g. text, unet_name, steps)')}>
<input
class="{inputClass} w-24"
placeholder={$i18n.t('Key')}
bind:value={node.key}
required
/>
</Tooltip>
</div>
<div class="px-2 text-gray-400 dark:text-gray-500">:</div>
<div class="w-full">
<Tooltip
content={$i18n.t('Comma separated Node Ids (e.g. 1 or 1,2)')}
placement="top-start"
<div class="mt-1 flex flex-col gap-1.5 text-xs">
{#each editWorkflowNodesConfig as node}
<div class="flex w-full flex-col">
<div class="shrink-0">
<div
class="line-clamp-1 text-gray-400 dark:text-gray-500"
title={node.type}
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
<span class="font-medium">{node.class_type}</span><span
class="opacity-60">::{node.key}</span
>
</div>
</div>
<div class="mt-0.5 flex items-center">
<div class="w-full">
<Tooltip
content={$i18n.t('Comma separated Node Ids (e.g. 1 or 1,2)')}
placement="top-start"
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
</div>
</div>
</div>
</div>
{/each}
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('*Prompt node ID(s) are required for image generation')}
</div>
{/each}
</div>
{:else}
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No configurable inputs detected. Upload a workflow in API format.')}
</div>
{/if}
</AdminSettingField>
{/if}
{:else if config?.IMAGE_EDIT_ENGINE === 'gemini'}