This commit is contained in:
G30 2026-08-26 09:02:48 +03:00 committed by GitHub
commit 20fa2be202
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 523 additions and 233 deletions

View file

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

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

View file

@ -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<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;
}
];
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(',')
};
});
}
});
</script>
@ -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);
}}
/>
<!-- {#if config.COMFYUI_WORKFLOW}
@ -624,51 +756,74 @@
{#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="capitalize line-clamp-1 w-20 text-gray-400 dark:text-gray-500"
title={node.type}
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
{node.class_type}
</div>
</div>
<div class="mt-0.5 flex 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"
>
<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'}
@ -850,13 +1005,13 @@
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;
parseEditWorkflowAndUpdateNodes(true, true);
(e.target as HTMLInputElement).value = '';
};
reader.readAsText(file);
}}
/>
@ -902,9 +1057,10 @@
lang="json"
onChange={(e) => {
config.IMAGES_EDIT_COMFYUI_WORKFLOW = e;
parseEditWorkflowAndUpdateNodes(false, false);
}}
onSave={() => {
console.log('Saved');
parseEditWorkflowAndUpdateNodes(true, false);
}}
/>
</div>
@ -912,51 +1068,74 @@
{#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="capitalize line-clamp-1 w-20 text-gray-400 dark:text-gray-500"
title={node.type}
>
<input
class={inputClass}
placeholder={$i18n.t('Node Ids')}
bind:value={node.node_ids}
/>
</Tooltip>
{node.class_type}
</div>
</div>
<div class="mt-0.5 flex 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"
>
<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'}

View file

@ -43,7 +43,6 @@
"{{NAMES}} reacted with {{REACTION}}": "",
"{{user}}'s Chats": "",
"{{webUIName}} Backend Required": "",
"*Prompt node ID(s) are required for image generation": "",
"1 file": "",
"1 group": "",
"1 hour before": "",
@ -319,6 +318,7 @@
"Auto Redirect": "",
"Auto-Copy Response to Clipboard": "",
"Auto-Create Groups": "",
"Auto-detected": "",
"Auto-Playback Response": "",
"Autocomplete Generation": "",
"Autocomplete Generation Input Max Length": "",
@ -1117,6 +1117,7 @@
"Error accessing Google Drive: {{error}}": "",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error occurred during workflow parsing.": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "",
@ -1501,6 +1502,8 @@
"Invalid JSON format in {{NAME}}": "",
"Invalid JSON format in Additional Config": "",
"Invalid JSON format in MinerU Parameters": "",
"Invalid JSON syntax in ComfyUI Workflow.": "",
"Invalid workflow data provided for parsing.": "",
"is typing...": "",
"Italic": "",
"January": "",
@ -1642,8 +1645,6 @@
"Map LDAP groups to Open WebUI groups.": "",
"Map OAuth claims to Open WebUI groups.": "",
"Map OAuth claims to Open WebUI roles.": "",
"Map workflow node inputs used for image edits.": "",
"Map workflow node inputs used for image generation.": "",
"Mapped Source": "",
"March": "",
"Mark all as read": "",
@ -1848,6 +1849,7 @@
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
"No configurable inputs detected. Upload a workflow in API format.": "",
"No content": "",
"No content found": "",
"No content to speak": "",
@ -1911,7 +1913,9 @@
"No valves to update": "",
"No webhooks yet": "",
"Node Ids": "",
"Node IDs are auto-detected from your workflow. Adjust them if needed.": "",
"None": "",
"Not a valid ComfyUI API Workflow JSON format. Make sure to export as API format from ComfyUI.": "",
"Not configured": "",
"Not factually correct": "",
"Not helpful": "",
@ -3037,6 +3041,9 @@
"Width in pixels to compress images to. Leave empty for no compression.": "",
"Wikipedia": "",
"Won": "",
"Workflow parsed, but no configurable primitive inputs were found. Ensure you exported in API format.": "",
"Workflow parsed. {{count}} configurable input(s) found. Please review the Node IDs._one": "",
"Workflow parsed. {{count}} configurable input(s) found. Please review the Node IDs._other": "",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "",