aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--comfier.py702
-rw-r--r--harness.py190
-rw-r--r--requirements.txt2
-rw-r--r--workflow.json379
5 files changed, 1274 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ba0430d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__/ \ No newline at end of file
diff --git a/comfier.py b/comfier.py
new file mode 100644
index 0000000..a97fd0d
--- /dev/null
+++ b/comfier.py
@@ -0,0 +1,702 @@
+"""
+title: Comfier
+author: Felix Morgner
+author_url: https://felixmorgner.com
+description: A ComfyUI image generation tool extending the built-in tool by allowing for more parameters.
+required_open_webui_version: 0.12.0
+version: 0.1.0
+license: 3-clause-BSD
+"""
+
+import aiohttp
+import asyncio
+import copy
+import inspect
+import json
+import mimetypes
+import random
+import time
+import uuid
+
+from fastapi import Request
+from pydantic import BaseModel, Field
+from typing import Annotated, Any, Awaitable, Callable, Optional
+
+from open_webui.models.config import Config
+from open_webui.models.users import Users
+from open_webui.routers.images import get_image_config, upload_image
+
+EventEmitter = Callable[[dict[str, Any]], Awaitable[None]]
+EventCall = Callable[[dict[str, Any]], Awaitable[Any]]
+
+
+class Tools:
+ class Valves(BaseModel):
+ negative_prompt: str = Field(
+ default="watermarks",
+ description="Tool level value for the negative prompt.",
+ )
+ negative_prompt_key: str = Field(
+ default="negative_prompt",
+ description="Tool-level input key name for the negative prompt (e.g. 'negative_prompt' or 'text').",
+ )
+ negative_prompt_node_ids: str = Field(
+ default="",
+ description="Tool-level node IDs for negative prompt targets, comma-separated (e.g. '459:452'). Leave empty to disable negative prompting.",
+ )
+
+ def __init__(self):
+ self.valves = self.Valves()
+
+ async def comfier_generate_seed(
+ self, bits: Annotated[int, Field(ge=8, le=63)] = 63
+ ) -> str:
+ """
+ Generate a new seed for image generation.
+ This seed can then be used for later calls of comfier_generate_image.
+
+ :param bits: The maximum bit-width of the generated seed (between 8 and 63).
+ :return: The generated seed.
+ """
+
+ clamped = max(8, min(int(bits), 63))
+ return f"{random.getrandbits(clamped)}"
+
+ async def comfier_generate_image(
+ self,
+ positive_prompt: str,
+ negative_prompt: Optional[str] = None,
+ size: Optional[Annotated[str, Field(pattern=r"^\d+[xX]\d+$")]] = None,
+ seed: Optional[int | str] = None,
+ __request__: Optional[Request] = None,
+ __user__: Optional[dict[str, Any]] = None,
+ __event_emitter__: Optional[EventEmitter] = None,
+ __event_call__: Optional[EventCall] = None,
+ ) -> dict[str, Any]:
+ """
+ Generate an image from a text prompt using an optional reproducible seed.
+ Additionally, this tool allows for the specification of a 'negative prompt'.
+
+ :param positive_prompt: The descriptive text prompt for the image to generate.
+ :param negative_prompt: Optional descriptive text prompt of what to avoid generating in the image.
+ :param size: Optional image dimensions in 'WIDTHxHEIGHT' format (e.g. '1024x768'). Default to the global Open WebUI size.
+ :param seed: Optional seed for reproducible generation.
+ :return: Generation status.
+ """
+
+ if __request__ is None:
+ raise RuntimeError(
+ "FastAPI Request context (__request__) was not injected."
+ )
+
+ if not __user__ or "id" not in __user__:
+ raise RuntimeError("User context (__user__) was not injected.")
+
+ normalized_seed = self._normalize_seed(seed)
+ workflow = await self._load_workflow()
+ global_mappings = await self._load_global_node_mappings()
+ effective_mappings = self._resolve_node_mappings(global_mappings)
+ effective_negative_prompt = (
+ negative_prompt
+ if negative_prompt is not None
+ else self.valves.negative_prompt
+ )
+ width, height = await self._resolve_dimensions(size)
+ steps = await self._resolve_steps()
+
+ parameter_values: dict[str, Any] = {
+ "positive_prompt": positive_prompt,
+ "negative_prompt": effective_negative_prompt,
+ "seed": normalized_seed,
+ "width": width,
+ "height": height,
+ "steps": steps,
+ }
+
+ self._apply_workflow_parameters(
+ workflow=workflow,
+ mappings=effective_mappings,
+ values=parameter_values,
+ )
+
+ user = Users.get_user_by_id(__user__["id"])
+ if inspect.isawaitable(user):
+ user = await user
+
+ while True:
+ try:
+ image_urls = await self._execute(
+ workflow,
+ request=__request__,
+ user=user,
+ metadata={
+ "prompt": positive_prompt,
+ "negative_prompt": effective_negative_prompt,
+ "seed": str(normalized_seed),
+ "width": width,
+ "height": height,
+ "steps": steps,
+ },
+ event_emitter=__event_emitter__,
+ )
+ break
+ except (ConnectionError, aiohttp.ClientError) as err:
+ if __event_call__ is None:
+ raise
+ await self._emit_status(
+ __event_emitter__,
+ f"ComfyUI connection lost: {err}. Waiting for retry confirmation...",
+ done=False,
+ )
+ should_retry = await __event_call__(
+ {
+ "type": "confirmation",
+ "data": {
+ "title": "ComfyUI Backend Disconnected",
+ "message": (
+ f"Lost connection to ComfyUI during generation ({err}).\n\n"
+ f"Would you like to retry with the same parameters (seed {normalized_seed}, {width}x{height})?"
+ ),
+ },
+ }
+ )
+ if not should_retry:
+ await self._emit_status(
+ __event_emitter__,
+ f"Generation failed: {err}",
+ done=True,
+ )
+ raise
+
+ if __event_emitter__ is not None and image_urls:
+ await __event_emitter__(
+ {
+ "type": "files",
+ "data": {
+ "files": [{"type": "image", "url": url} for url in image_urls]
+ },
+ }
+ )
+
+ return {
+ "status": "success",
+ "seed": str(normalized_seed),
+ "message": "The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.",
+ "images": image_urls,
+ }
+
+ def _apply_workflow_parameters(
+ self,
+ workflow: dict[str, Any],
+ mappings: dict[str, dict[str, Any]],
+ values: dict[str, Any],
+ ) -> dict[str, list[str]]:
+ """Inject the resolved parameter values into the workflow dictionary in-place."""
+
+ applied: dict[str, list[str]] = {}
+
+ for param_name, value in values.items():
+ mapping = mappings.get(param_name)
+
+ if not mapping or value is None:
+ continue
+
+ input_key = mapping.get("key")
+ node_ids = mapping.get("node_ids", [])
+
+ if not input_key or not node_ids:
+ continue
+
+ updated_targets: list[str] = []
+
+ for node_id in node_ids:
+ node = workflow.get(node_id)
+
+ if not isinstance(node, dict) or not isinstance(
+ node.get("inputs"), dict
+ ):
+ raise KeyError(
+ f"Mapped node {node_id!r} for {param_name!r} does not exist in COMFYUI_WORKFLOW."
+ )
+
+ node["inputs"][input_key] = value
+ updated_targets.append(f"{node_id}.{input_key}")
+
+ if updated_targets:
+ applied[param_name] = updated_targets
+
+ return applied
+
+ async def _cancel(
+ self, http_base: str, headers: dict[str, str], prompt_id: Optional[str]
+ ) -> None:
+ """Try to cancel a ComfyUI request."""
+
+ if not prompt_id:
+ return
+
+ try:
+ timeout = aiohttp.ClientTimeout(total=3.0)
+ async with aiohttp.ClientSession(
+ headers=headers, timeout=timeout
+ ) as cancel_session:
+ await cancel_session.post(
+ f"{http_base}/queue", json={"delete": [prompt_id]}
+ )
+ await cancel_session.post(
+ f"{http_base}/interrupt", json={"prompt_id": prompt_id}
+ )
+ except Exception:
+ pass
+
+ async def _verify_prompt_alive(
+ self,
+ session: aiohttp.ClientSession,
+ http_base: str,
+ prompt_id: str,
+ ) -> bool:
+ """
+ Verify with ComfyUI that the backend is reachable and still tracking prompt_id
+ in either /queue (running/pending) or /history.
+ """
+
+ timeout = aiohttp.ClientTimeout(total=3.0)
+ try:
+ async with session.get(f"{http_base}/queue", timeout=timeout) as q_resp:
+ q_resp.raise_for_status()
+ q_data = await q_resp.json()
+
+ running = q_data.get("queue_running") or []
+ pending = q_data.get("queue_pending") or []
+ in_queue = any(
+ isinstance(item, list) and len(item) > 1 and item[1] == prompt_id
+ for item in (running + pending)
+ )
+ if in_queue:
+ return True
+
+ async with session.get(
+ f"{http_base}/history/{prompt_id}", timeout=timeout
+ ) as h_resp:
+ h_resp.raise_for_status()
+ h_data = await h_resp.json() or {}
+ if prompt_id not in h_data:
+ return False
+
+ status_info = (h_data[prompt_id] or {}).get("status") or {}
+ if status_info.get("status_str") == "error":
+ raise RuntimeError(
+ "The job was terminated win an error or interruption on the backend."
+ )
+ return True
+
+ except aiohttp.ClientError as err:
+ raise ConnectionError(
+ f"Lost connection to ComfyUI backend ({err})."
+ ) from err
+
+ async def _emit_status(
+ self,
+ event_emitter: Optional[EventEmitter],
+ description: str,
+ done: bool = False,
+ ) -> None:
+ """Send a live status update banner to the Open WebUI frontend."""
+
+ if event_emitter is None:
+ return
+
+ await event_emitter(
+ {
+ "type": "status",
+ "data": {
+ "description": description,
+ "done": done,
+ "hidden": False,
+ },
+ }
+ )
+
+ async def _execute(
+ self,
+ workflow: dict[str, Any],
+ request: Request,
+ user: Any,
+ metadata: dict[str, Any],
+ event_emitter: Optional[EventEmitter],
+ ) -> list[str]:
+ """Submit the workflow to ComfyUI, stream progress, and store the generated images."""
+
+ http_base, api_key = await self._load_connection_information()
+ ws_base = http_base.replace("https://", "wss://").replace("http://", "ws://")
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
+ client_id = str(uuid.uuid4())
+
+ output_images: list[dict[str, Any]] = []
+ stored_urls: list[str] = []
+ last_emit_time = 0.0
+ prompt_id: Optional[str] = None
+ idle_ticks = 0
+
+ try:
+ async with aiohttp.ClientSession(headers=headers) as session:
+ async with session.ws_connect(
+ f"{ws_base}/ws?clientId={client_id}",
+ heartbeat=5.0,
+ ) as ws:
+ await self._emit_status(
+ event_emitter, "Submitting workflow to ComfyUI...", done=False
+ )
+
+ async with session.post(
+ f"{http_base}/prompt",
+ json={"prompt": workflow, "client_id": client_id},
+ ) as response:
+ if response.status >= 400:
+ error_body = await response.text()
+ raise RuntimeError(
+ f"ComfyUI rejected workflow (HTTP {response.status}): {error_body}"
+ )
+ queued = await response.json()
+ prompt_id = queued["prompt_id"]
+
+ await self._emit_status(
+ event_emitter, "Waiting for ComfyUI queue...", done=False
+ )
+
+ while True:
+ try:
+ msg = await asyncio.wait_for(ws.receive(), timeout=1.0)
+ idle_ticks = 0
+ except asyncio.TimeoutError:
+ idle_ticks += 1
+ if idle_ticks >= 5 and prompt_id:
+ idle_ticks = 0
+ is_alive = await self._verify_prompt_alive(
+ session, http_base, prompt_id
+ )
+ if not is_alive:
+ raise ConnectionError(
+ f"ComfyUI backend lost prompt {prompt_id} (backend may have restarted or crashed)."
+ )
+ continue
+
+ if msg.type != aiohttp.WSMsgType.TEXT:
+ if msg.type in (
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSING,
+ aiohttp.WSMsgType.CLOSED,
+ aiohttp.WSMsgType.ERROR,
+ ):
+ ws_err = ws.exception()
+ detail = (
+ f": {ws_err}"
+ if ws_err
+ else f" (code {ws.close_code})"
+ )
+ raise ConnectionError(
+ f"ComfyUI WebSocket disconnected unexpectedly before completion{detail}."
+ )
+ continue
+
+ payload = json.loads(msg.data) or {}
+ msg_type = payload.get("type")
+ data = payload.get("data") or {}
+
+ if data.get("prompt_id") and data.get("prompt_id") != prompt_id:
+ continue
+
+ if msg_type == "executing":
+ node_id = data.get("node")
+ if node_id is None:
+ break
+ node_info = workflow.get(str(node_id)) or {}
+ node_meta = node_info.get("_meta") or {}
+ node_title = (
+ node_meta.get("title")
+ or node_info.get("class_type")
+ or f"Node {node_id}"
+ )
+ await self._emit_status(
+ event_emitter, f"Running: {node_title}...", done=False
+ )
+
+ elif msg_type == "progress":
+ val = data.get("value", 0)
+ maximum = data.get("max", 1)
+ percentage = int((val / maximum) * 100) if maximum else 0
+ now = time.monotonic()
+ if (now - last_emit_time) >= 0.25 or val == maximum:
+ last_emit_time = now
+ await self._emit_status(
+ event_emitter,
+ f"Sampling: step {val}/{maximum} ({percentage}%)",
+ done=False,
+ )
+
+ elif msg_type == "executed":
+ output_data = data.get("output") or {}
+ images = output_data.get("images") or []
+ if isinstance(images, list):
+ output_images.extend(
+ img for img in images if isinstance(img, dict)
+ )
+
+ elif msg_type == "execution_interrupted":
+ raise RuntimeError(
+ "Generation was interrupted on the backend."
+ )
+
+ elif msg_type == "execution_error":
+ error_message = data.get(
+ "exception_message", "Unknown ComfyUI error"
+ )
+ raise RuntimeError(
+ f"ComfyUI execution error: {error_message}"
+ )
+
+ if not output_images:
+ raise RuntimeError(
+ "Generation finished without producing any output. The Job may have beend cancelled or removed from the queue."
+ )
+
+ await self._emit_status(
+ event_emitter, "Saving generated images...", done=False
+ )
+
+ for img in output_images:
+ filename = img.get("filename")
+ if not filename:
+ continue
+
+ params = {
+ "filename": filename,
+ "subfolder": img.get("subfolder", ""),
+ "type": img.get("type", "output"),
+ }
+
+ async with session.get(
+ f"{http_base}/view", params=params
+ ) as img_response:
+ img_response.raise_for_status()
+ image_bytes = await img_response.read()
+ content_type = (
+ img_response.headers.get("Content-Type")
+ or mimetypes.guess_type(filename)[0]
+ or "image/png"
+ )
+
+ uploaded = upload_image(
+ request=request,
+ image_data=image_bytes,
+ content_type=content_type,
+ metadata=metadata,
+ user=user,
+ )
+
+ if inspect.isawaitable(uploaded):
+ uploaded = await uploaded
+
+ if isinstance(uploaded, tuple):
+ uploaded = uploaded[-1]
+
+ if isinstance(uploaded, str):
+ url = uploaded
+ elif hasattr(uploaded, "id"):
+ url = f"/api/v1/files/{uploaded.id}/content"
+ elif isinstance(uploaded, dict) and "id" in uploaded:
+ url = f"/api/v1/files/{uploaded['id']}/content"
+ else:
+ raise RuntimeError(
+ f"Unexpected return type from upload_image: {type(uploaded)!r}"
+ )
+
+ stored_urls.append(url)
+
+ except asyncio.CancelledError:
+ await asyncio.shield(self._cancel(http_base, headers, prompt_id))
+ await asyncio.shield(
+ self._emit_status(
+ event_emitter, "Generation aborted by user", done=True
+ )
+ )
+ raise
+
+ except Exception as err:
+ await self._emit_status(
+ event_emitter, f"Generation failed: {err}", done=True
+ )
+ raise
+
+ await self._emit_status(event_emitter, "Generation complete", done=True)
+ return stored_urls
+
+ async def _load_connection_information(self) -> tuple[str, str]:
+ """Load COMFYUI_BASE_URL and COMFYUI_API_KEY from Open WebUI config."""
+
+ image_cfg = await get_image_config()
+ base_url = getattr(image_cfg, "COMFYUI_BASE_URL", None)
+ api_key = getattr(image_cfg, "COMFYUI_API_KEY", None)
+
+ if not base_url:
+ base_url = await Config.get("image_generation.comfyui.base_url", "")
+
+ if api_key is None:
+ api_key = await Config.get("image_generation.comfyui.api_key", "")
+
+ base_url = str(base_url or "").strip().rstrip("/")
+
+ if not base_url:
+ raise RuntimeError("COMFYUI_BASE_URL is not configured in Open WebUI.")
+
+ return base_url, str(api_key or "").strip()
+
+ async def _load_global_node_mappings(self) -> list[dict[str, Any]]:
+ """Load the globally configured node mappings (COMFYUI_WORKFLOW_NODES) list."""
+
+ image_cfg = await get_image_config()
+ nodes = getattr(image_cfg, "COMFYUI_WORKFLOW_NODES", None)
+
+ if nodes is None:
+ nodes = await Config.get("image_generation.comfyui.nodes", [])
+
+ return nodes if isinstance(nodes, list) else []
+
+ async def _load_workflow(self) -> dict[str, Any]:
+ """Load and parse the globally configured ComfyUI workflow JSON."""
+
+ image_cfg = await get_image_config()
+ raw_workflow = getattr(image_cfg, "COMFYUI_WORKFLOW", None)
+
+ if not raw_workflow:
+ raw_workflow = await Config.get("image_generation.comfyui.workflow", "")
+
+ if isinstance(raw_workflow, dict):
+ return copy.deepcopy(raw_workflow)
+
+ if not isinstance(raw_workflow, str) or not raw_workflow.strip():
+ raise RuntimeError(
+ "No global COMFYUI_WORKFLOW is configured in Open WebUI."
+ )
+
+ try:
+ workflow = json.loads(raw_workflow)
+ except json.JSONDecodeError as err:
+ raise RuntimeError(
+ "Configured COMFYUI_WORKFLOW is not valid JSON."
+ ) from err
+
+ if not isinstance(workflow, dict):
+ raise RuntimeError(
+ "Configured COMFYUI_WORKFLOW root must be a JSON object."
+ )
+
+ return workflow
+
+ def _normalize_seed(self, seed: Optional[int | str]) -> int:
+ """Normalize an optional int or string seed into a valid 63-bit positive integer."""
+
+ if seed is None:
+ return random.getrandbits(63)
+
+ if isinstance(seed, str):
+ stripped = seed.strip()
+ if not stripped:
+ return random.getrandbits(63)
+ try:
+ parsed = int(stripped, 0)
+ except ValueError as err:
+ raise ValueError(f"Invalid seed string: {seed!r}") from err
+ else:
+ parsed = int(seed)
+
+ if parsed < 0:
+ return random.getrandbits(63)
+
+ return parsed & ((1 << 63) - 1)
+
+ async def _resolve_dimensions(self, size: Optional[str]) -> tuple[int, int]:
+ """Parse 'WIDTHxHEIGHT' from the size parameter or fall back to global IMAGE_SIZE."""
+
+ raw_size = (size or "").strip()
+
+ if not raw_size:
+ image_cfg = await get_image_config()
+ raw_size = getattr(image_cfg, "IMAGE_SIZE", None)
+
+ if not raw_size:
+ raw_size = await Config.get("image_generation.size", "1024x1024")
+
+ try:
+ width_string, height_string = str(raw_size).lower().split("x", 1)
+ width, height = int(width_string.strip()), int(height_string.strip())
+ except (ValueError, AttributeError) as err:
+ raise ValueError(
+ f"Invalid image size {raw_size!r}. Expected 'WIDTHxHEIGHT' (e.g. '1024x768')."
+ ) from err
+
+ if width <= 0 or height <= 0:
+ raise ValueError(f"Image dimensions must be positive: {width}x{height}")
+
+ return width, height
+
+ def _resolve_node_mappings(
+ self, global_nodes: list[dict[str, Any]]
+ ) -> dict[str, dict[str, Any]]:
+ """Combine global prompt/seed mappings with the tool's negative_prompt Valve."""
+
+ resolved: dict[str, dict[str, Any]] = {
+ "positive_prompt": {"key": "text", "node_ids": []},
+ "negative_prompt": {
+ "key": self.valves.negative_prompt_key.strip() or "negative_prompt",
+ "node_ids": [
+ nid.strip()
+ for nid in self.valves.negative_prompt_node_ids.split(",")
+ if nid.strip()
+ ],
+ },
+ "seed": {"key": "seed", "node_ids": []},
+ "width": {"key": "width", "node_ids": []},
+ "height": {"key": "height", "node_ids": []},
+ "steps": {"key": "steps", "node_ids": []},
+ }
+
+ type_map = {
+ "prompt": "positive_prompt",
+ "positive_prompt": "positive_prompt",
+ "seed": "seed",
+ "width": "width",
+ "height": "height",
+ "steps": "steps",
+ }
+
+ for entry in global_nodes:
+ if not isinstance(entry, dict):
+ continue
+
+ target = type_map.get(entry.get("type", ""))
+
+ if not target:
+ continue
+
+ if entry.get("key"):
+ resolved[target]["key"] = str(entry["key"]).strip()
+
+ if isinstance(entry.get("node_ids"), list):
+ resolved[target]["node_ids"] = [
+ str(nid).strip() for nid in entry["node_ids"] if str(nid).strip()
+ ]
+
+ return resolved
+
+ async def _resolve_steps(self) -> int:
+ """Load the global IMAGE_STEPS setting from Open WebUI config."""
+
+ image_cfg = await get_image_config()
+ raw_steps = getattr(image_cfg, "IMAGE_STEPS", None)
+
+ if raw_steps is None:
+ raw_steps = await Config.get("image_generation.steps", 25)
+
+ return int(raw_steps)
+
diff --git a/harness.py b/harness.py
new file mode 100644
index 0000000..f588328
--- /dev/null
+++ b/harness.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+import argparse
+import asyncio
+import json
+import pathlib
+import sys
+import types
+from types import SimpleNamespace
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# 1. Stub out `fastapi` and `open_webui.*` in sys.modules BEFORE importing tool
+# ---------------------------------------------------------------------------
+def install_open_webui_stubs(
+ comfyui_url: str,
+ api_key: str,
+ workflow_path: pathlib.Path,
+ nodes_config: list[dict[str, Any]],
+ image_size: str,
+ image_steps: int,
+ output_dir: pathlib.Path,
+) -> None:
+ # Ensure fastapi.Request exists even in a minimal venv
+ if "fastapi" not in sys.modules:
+ fastapi_mod = types.ModuleType("fastapi")
+ fastapi_mod.Request = object
+ sys.modules["fastapi"] = fastapi_mod
+
+ workflow_raw = workflow_path.read_text(encoding="utf-8")
+
+ # Mock `open_webui.models.config.Config`
+ config_mod = types.ModuleType("open_webui.models.config")
+
+ class MockConfig:
+ @staticmethod
+ async def get(key: str, default: Any = None) -> Any:
+ return default
+
+ config_mod.Config = MockConfig
+
+ # Mock `open_webui.models.users.Users`
+ users_mod = types.ModuleType("open_webui.models.users")
+
+ class MockUsers:
+ @staticmethod
+ def get_user_by_id(user_id: str) -> Any:
+ return SimpleNamespace(id=user_id, name="Felix")
+
+ users_mod.Users = MockUsers
+
+ # Mock `open_webui.routers.images`
+ images_mod = types.ModuleType("open_webui.routers.images")
+
+ async def mock_get_image_config() -> Any:
+ return SimpleNamespace(
+ COMFYUI_BASE_URL=comfyui_url,
+ COMFYUI_API_KEY=api_key,
+ COMFYUI_WORKFLOW=workflow_raw,
+ COMFYUI_WORKFLOW_NODES=nodes_config,
+ IMAGE_SIZE=image_size,
+ IMAGE_STEPS=image_steps,
+ )
+
+ def mock_upload_image(
+ request: Any,
+ image_data: bytes,
+ content_type: str,
+ metadata: dict[str, Any],
+ user: Any,
+ ) -> Any:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ ext = ".png" if "png" in content_type else ".jpg"
+ file_id = f"seed_{metadata.get('seed', 'unknown')}_{int(asyncio.get_event_loop().time() * 1000)}"
+ out_file = output_dir / f"{file_id}{ext}"
+ out_file.write_bytes(image_data)
+ print(f" [Storage] Saved {len(image_data)} bytes -> {out_file}")
+ # Return a mock FileModel with an `.id` attribute just like Open WebUI
+ return SimpleNamespace(id=file_id)
+
+ images_mod.get_image_config = mock_get_image_config
+ images_mod.upload_image = mock_upload_image
+
+ # Register package hierarchy in sys.modules
+ sys.modules["open_webui"] = types.ModuleType("open_webui")
+ sys.modules["open_webui.models"] = types.ModuleType("open_webui.models")
+ sys.modules["open_webui.routers"] = types.ModuleType("open_webui.routers")
+ sys.modules["open_webui.models.config"] = config_mod
+ sys.modules["open_webui.models.users"] = users_mod
+ sys.modules["open_webui.routers.images"] = images_mod
+
+
+# ---------------------------------------------------------------------------
+# 2. Terminal Event Emitter & Interactive Event Call
+# ---------------------------------------------------------------------------
+async def console_event_emitter(event: dict[str, Any]) -> None:
+ ev_type = event.get("type")
+ data = event.get("data", {})
+ if ev_type == "status":
+ done_marker = "✓" if data.get("done") else "…"
+ print(f" [{done_marker}] {data.get('description')}")
+ elif ev_type == "files":
+ print(f" [Files Emitted] {json.dumps(data.get('files', []))}")
+ else:
+ print(f" [Event:{ev_type}] {data}")
+
+
+async def console_event_call(event: dict[str, Any]) -> bool:
+ ev_type = event.get("type")
+ data = event.get("data", {})
+ print(f"\n--- MODAL ({ev_type}): {data.get('title')} ---")
+ print(data.get("message", ""))
+ loop = asyncio.get_running_loop()
+ answer = await loop.run_in_executor(
+ None, lambda: input("Confirm / Retry? [y/N]: ").strip().lower()
+ )
+ return answer in ("y", "yes")
+
+
+# ---------------------------------------------------------------------------
+# 3. CLI Runner
+# ---------------------------------------------------------------------------
+async def main() -> None:
+ parser = argparse.ArgumentParser(description="Standalone test harness for Comfier")
+ parser.add_argument("--url", default="http://10.42.0.146:8188", help="ComfyUI base URL")
+ parser.add_argument("--workflow", type=pathlib.Path, required=True, help="Path to ComfyUI API workflow JSON")
+ parser.add_argument("--prompt", default="a tiny blue marble on a white surface", help="Positive prompt")
+ parser.add_argument("--neg", default=None, help="Negative prompt")
+ parser.add_argument("--size", default="256x256", help="Image size (WIDTHxHEIGHT)")
+ parser.add_argument("--seed", default="123", help="Seed")
+ parser.add_argument("--steps", type=int, default=30, help="Global IMAGE_STEPS")
+ parser.add_argument("--neg-nodes", default="459:452", help="Valve: negative_prompt_node_ids")
+ parser.add_argument("--cancel-after", type=float, default=None, help="Simulate user clicking Stop after N seconds")
+ args = parser.parse_args()
+
+ # Match your Open WebUI global COMFYUI_WORKFLOW_NODES setup
+ global_nodes = [
+ {"type": "prompt", "key": "value", "node_ids": ["520:506"]},
+ {"type": "seed", "key": "seed", "node_ids": ["520:517"]},
+ {"type": "width", "key": "value", "node_ids": ["520:518"]},
+ {"type": "height", "key": "value", "node_ids": ["520:519"]},
+ {"type": "steps", "key": "steps", "node_ids": ["459:458"]},
+ ]
+
+ install_open_webui_stubs(
+ comfyui_url=args.url,
+ api_key="",
+ workflow_path=args.workflow,
+ nodes_config=global_nodes,
+ image_size="1024x1024",
+ image_steps=args.steps,
+ output_dir=pathlib.Path("./test_out"),
+ )
+
+ # Import your tool AFTER stubs are installed
+ from comfier import Tools
+
+ tools = Tools()
+ tools.valves.negative_prompt_node_ids = args.neg_nodes
+
+ task = asyncio.create_task(
+ tools.comfier_generate_image(
+ positive_prompt=args.prompt,
+ negative_prompt=args.neg,
+ size=args.size,
+ seed=args.seed,
+ __request__=SimpleNamespace(),
+ __user__={"id": "local-dev"},
+ __event_emitter__=console_event_emitter,
+ __event_call__=console_event_call,
+ )
+ )
+
+ if args.cancel_after is not None:
+ await asyncio.sleep(args.cancel_after)
+ print(f"\n[Harness] Simulating user abort after {args.cancel_after}s...")
+ task.cancel()
+
+ try:
+ result = await task
+ print("\n[Tool Return Payload]")
+ print(json.dumps(result, indent=2))
+ except asyncio.CancelledError:
+ print("\n[Harness] Task cleanly cancelled.")
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ print("\nInterrupted via Ctrl+C.")
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7925b0b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+aiohttp
+pydantic \ No newline at end of file
diff --git a/workflow.json b/workflow.json
new file mode 100644
index 0000000..40d24a8
--- /dev/null
+++ b/workflow.json
@@ -0,0 +1,379 @@
+{
+ "513": {
+ "inputs": {
+ "images": [
+ "459:525",
+ 0
+ ]
+ },
+ "class_type": "PreviewImage",
+ "_meta": {
+ "title": "Output"
+ }
+ },
+ "520:506": {
+ "inputs": {
+ "value": ""
+ },
+ "class_type": "PrimitiveStringMultiline",
+ "_meta": {
+ "title": "Prompt"
+ }
+ },
+ "520:518": {
+ "inputs": {
+ "value": 1530
+ },
+ "class_type": "PrimitiveInt",
+ "_meta": {
+ "title": "Width"
+ }
+ },
+ "520:519": {
+ "inputs": {
+ "value": 1024
+ },
+ "class_type": "PrimitiveInt",
+ "_meta": {
+ "title": "Height"
+ }
+ },
+ "520:517": {
+ "inputs": {
+ "seed": 0
+ },
+ "class_type": "SeedNode",
+ "_meta": {
+ "title": "Generation Seed"
+ }
+ },
+ "520:526": {
+ "inputs": {
+ "value": ""
+ },
+ "class_type": "PrimitiveStringMultiline",
+ "_meta": {
+ "title": "Negative Prompt"
+ }
+ },
+ "459:452": {
+ "inputs": {
+ "prompt": [
+ "520:506",
+ 0
+ ],
+ "negative_prompt": [
+ "520:526",
+ 0
+ ],
+ "resolution": 1024,
+ "clip": [
+ "459:453",
+ 0
+ ],
+ "vae": [
+ "459:454",
+ 0
+ ]
+ },
+ "class_type": "TextEncodeQwenImage21",
+ "_meta": {
+ "title": "Text Encode Qwen Image 2.1"
+ }
+ },
+ "459:453": {
+ "inputs": {
+ "clip_name": "qwen3vl_8b_bf16.safetensors",
+ "type": "qwen_image",
+ "device": "default"
+ },
+ "class_type": "CLIPLoader",
+ "_meta": {
+ "title": "Load Conditioning Model"
+ }
+ },
+ "459:454": {
+ "inputs": {
+ "vae_name": "qwen_image_2.1_vae_bf16.safetensors"
+ },
+ "class_type": "VAELoader",
+ "_meta": {
+ "title": "Load VAE"
+ }
+ },
+ "459:456": {
+ "inputs": {
+ "width": [
+ "520:518",
+ 0
+ ],
+ "height": [
+ "520:519",
+ 0
+ ],
+ "batch_size": 1
+ },
+ "class_type": "EmptyLatentImage",
+ "_meta": {
+ "title": "Empty Latent Image"
+ }
+ },
+ "459:457": {
+ "inputs": {
+ "samples": [
+ "459:458",
+ 0
+ ],
+ "vae": [
+ "459:454",
+ 0
+ ]
+ },
+ "class_type": "VAEDecode",
+ "_meta": {
+ "title": "VAE Decode"
+ }
+ },
+ "459:458": {
+ "inputs": {
+ "seed": [
+ "520:517",
+ 0
+ ],
+ "steps": 25,
+ "cfg": 3,
+ "sampler_name": "euler",
+ "scheduler": "sgm_uniform",
+ "denoise": 1,
+ "model": [
+ "459:468",
+ 0
+ ],
+ "positive": [
+ "459:452",
+ 0
+ ],
+ "negative": [
+ "459:452",
+ 1
+ ],
+ "latent_image": [
+ "459:456",
+ 0
+ ]
+ },
+ "class_type": "KSampler",
+ "_meta": {
+ "title": "KSampler"
+ }
+ },
+ "459:468": {
+ "inputs": {
+ "unet_name": "qwen-image-2.1-UC-Q4_K_M.gguf"
+ },
+ "class_type": "UnetLoaderGGUF",
+ "_meta": {
+ "title": "Load Inference Model"
+ }
+ },
+ "459:472": {
+ "inputs": {
+ "model_name": "4xPurePhoto-RealPLSKR.pth"
+ },
+ "class_type": "UpscaleModelLoader",
+ "_meta": {
+ "title": "Load Upscale Model"
+ }
+ },
+ "459:473": {
+ "inputs": {
+ "upscale_model": [
+ "459:472",
+ 0
+ ],
+ "image": [
+ "459:525",
+ 0
+ ]
+ },
+ "class_type": "ImageUpscaleWithModel",
+ "_meta": {
+ "title": "Upscale Image (using Model)"
+ }
+ },
+ "459:488": {
+ "inputs": {
+ "filename_prefix": [
+ "459:498",
+ 0
+ ],
+ "format": "md",
+ "text": [
+ "459:516",
+ 0
+ ]
+ },
+ "class_type": "SaveText",
+ "_meta": {
+ "title": "Save Prompt"
+ }
+ },
+ "459:494": {
+ "inputs": {
+ "format_string": "%Y-%m-%d_%H-%M-%S",
+ "datetime": [
+ "459:495",
+ 0
+ ]
+ },
+ "class_type": "Basic data handling: TimeFormat",
+ "_meta": {
+ "title": "Format Time String"
+ }
+ },
+ "459:495": {
+ "inputs": {
+ "trigger": [
+ "459:458",
+ 0
+ ]
+ },
+ "class_type": "Basic data handling: TimeNow",
+ "_meta": {
+ "title": "Time Now"
+ }
+ },
+ "459:497": {
+ "inputs": {
+ "f_string": "ai.arknet.ch/generated-image_{a}",
+ "values.a": [
+ "459:494",
+ 0
+ ]
+ },
+ "class_type": "StringFormat",
+ "_meta": {
+ "title": "Base Name"
+ }
+ },
+ "459:498": {
+ "inputs": {
+ "f_string": "{a}_prompt",
+ "values.a": [
+ "459:497",
+ 0
+ ]
+ },
+ "class_type": "StringFormat",
+ "_meta": {
+ "title": "Prompt Name"
+ }
+ },
+ "459:514": {
+ "inputs": {
+ "sub_directory": "ai.arknet.ch",
+ "filename_text_1": "generated-image",
+ "filename_text_2": "base",
+ "filename_text_3": [
+ "459:494",
+ 0
+ ],
+ "filename_separator": "_",
+ "timestamp": "false",
+ "counter_type": "filename",
+ "filename_text_1_pos": 0,
+ "filename_text_2_pos": 2,
+ "filename_text_3_pos": 1,
+ "timestamp_pos": 4,
+ "timestamp_type": "job",
+ "counter_pos": 3,
+ "extra_metadata": "",
+ "images": [
+ "459:525",
+ 0
+ ]
+ },
+ "class_type": "Save Images No Display",
+ "_meta": {
+ "title": "Save Base Image"
+ }
+ },
+ "459:515": {
+ "inputs": {
+ "sub_directory": "ai.arknet.ch",
+ "filename_text_1": "generated-image",
+ "filename_text_2": "upscaled",
+ "filename_text_3": [
+ "459:494",
+ 0
+ ],
+ "filename_separator": "_",
+ "timestamp": "false",
+ "counter_type": "filename",
+ "filename_text_1_pos": 0,
+ "filename_text_2_pos": 2,
+ "filename_text_3_pos": 1,
+ "timestamp_pos": 4,
+ "timestamp_type": "job",
+ "counter_pos": 3,
+ "extra_metadata": "",
+ "images": [
+ "459:523",
+ 0
+ ]
+ },
+ "class_type": "Save Images No Display",
+ "_meta": {
+ "title": "Save Upscaled Image"
+ }
+ },
+ "459:516": {
+ "inputs": {
+ "f_string": "# Positive Prompt\n\n{a}\n\n---\n\n# Negative Prompt\n\n{b}\n\n---\n\nSEED: {c}",
+ "values.a": [
+ "520:506",
+ 0
+ ],
+ "values.b": [
+ "520:526",
+ 0
+ ],
+ "values.c": [
+ "520:517",
+ 0
+ ]
+ },
+ "class_type": "StringFormat",
+ "_meta": {
+ "title": "Format Text"
+ }
+ },
+ "459:523": {
+ "inputs": {
+ "value": [
+ "459:473",
+ 0
+ ],
+ "model": [
+ "459:472",
+ 0
+ ]
+ },
+ "class_type": "UnloadModel",
+ "_meta": {
+ "title": "UnloadModel"
+ }
+ },
+ "459:525": {
+ "inputs": {
+ "value": [
+ "459:457",
+ 0
+ ]
+ },
+ "class_type": "UnloadAllModels",
+ "_meta": {
+ "title": "UnloadAllModels"
+ }
+ }
+}