Image tools: migrate to OWUI 0.9.0 async model accessors

Open WebUI 0.9.0 made every model-class accessor (Users.get_user_by_id,
Chats.get_chat_by_id, Files.get_file_by_id, …) a coroutine. Both tools
were still calling them synchronously, so the calls returned coroutines
instead of model objects; the first downstream attribute access threw,
the bare `except Exception: return False` swallowed it, and uploads
silently fell through to the data-URI fallback. The data-URI markdown
rendered during streaming but didn't survive post-stream commit, which
looked like "image flashes in, then disappears."

Add await to the six call sites; promote `_read_file_dict` to async
since it now contains an await; restore `_push_image_to_chat` to the
canonical `files` event so the file-attachment chrome (thumbnail +
download) comes back.

This supersedes commit d034700, which mis-diagnosed the symptom as a
virtualization regression and switched to a `message`-event markdown
workaround. The workaround didn't help (same flash-and-vanish) because
the upload pre-check still failed for the same async-migration reason
and the data-URI fallback path still ran.

smart_image_gen.py 0.7.9 -> 0.7.10
smart_image_pipe.py 0.1.1 -> 0.1.2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 06:16:02 -05:00
co-authored by Claude Opus 4.7
parent d034700af9
commit c07e962cae
2 changed files with 35 additions and 65 deletions
@@ -1,7 +1,7 @@
"""
title: Smart Image Generator & Editor (ComfyUI)
author: ai-stack
version: 0.7.9
version: 0.7.10
description: Generate or edit images via ComfyUI with automatic SDXL
checkpoint routing. Two methods — generate_image (txt2img) and
edit_image (img2img on the user's most recently attached image). The
@@ -9,8 +9,8 @@ description: Generate or edit images via ComfyUI with automatic SDXL
score-tag, NoobAI/Illustrious furry, etc. — and each style ships
with the creator-recommended sampler, scheduler, CFG, steps, CLIP
skip, prompt-prefix dialect, and negatives. The image is uploaded
to Open WebUI's file store and surfaced as a markdown image
appended to the assistant message via a `message` event; the
to Open WebUI's file store and surfaced via a `files` event (the
canonical pattern used by Open WebUI's own image-gen path); the
function return is a short confirmation so the LLM doesn't try to
describe or re-emit the image.
required_open_webui_version: 0.5.0
@@ -405,7 +405,7 @@ def _file_dict_is_image(f: dict) -> bool:
_FILE_URL_ID_RE = re.compile(r"/(?:api/v1/)?files/([0-9a-fA-F-]{8,})(?:/content)?")
def _read_file_dict(f: dict) -> Optional[bytes]:
async def _read_file_dict(f: dict) -> Optional[bytes]:
"""
Try to read raw bytes for one file dict. Tries in order:
1. Local filesystem path keys (covers user uploads with `path`).
@@ -415,6 +415,12 @@ def _read_file_dict(f: dict) -> Optional[bytes]:
assistant-emitted files where the message attachment is just
{"type":"image","url":"/api/v1/files/<uuid>/content"} —
no id field, no path field, but the URL has the id).
Async because Open WebUI 0.9.0 made every model-class accessor
a coroutine (Users / Chats / Files / etc.). Calling the sync
way returns a coroutine object instead of the model — silently
breaks downstream attribute access. Same reason the callers in
_extract_attached_image and _push_image_to_chat must await.
"""
for path_key in ("path", "filepath", "file_path"):
path = f.get(path_key)
@@ -437,7 +443,7 @@ def _read_file_dict(f: dict) -> Optional[bytes]:
if _OPENWEBUI_RUNTIME:
for fid in candidate_ids:
try:
file_model = Files.get_file_by_id(fid)
file_model = await Files.get_file_by_id(fid)
if file_model is None:
continue
path = getattr(file_model, "path", None)
@@ -506,7 +512,7 @@ async def _extract_attached_image(
for f in msg_files:
if not isinstance(f, dict) or not _file_dict_is_image(f):
continue
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
@@ -514,7 +520,7 @@ async def _extract_attached_image(
for f in files or []:
if not isinstance(f, dict) or not _file_dict_is_image(f):
continue
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
@@ -526,7 +532,7 @@ async def _extract_attached_image(
chat_id = metadata.get("chat_id")
if chat_id:
try:
chat = Chats.get_chat_by_id(chat_id)
chat = await Chats.get_chat_by_id(chat_id)
chat_data = getattr(chat, "chat", None) if chat else None
chat_messages = (chat_data or {}).get("messages", []) if isinstance(chat_data, dict) else []
for msg in reversed(chat_messages):
@@ -536,7 +542,7 @@ async def _extract_attached_image(
for f in msg_files:
if not isinstance(f, dict) or not _file_dict_is_image(f):
continue
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
except Exception:
@@ -586,28 +592,20 @@ async def _push_image_to_chat(
event_emitter: Optional[Callable[[dict], Awaitable[None]]],
) -> bool:
"""
Surface a generated image in the chat: upload the bytes via the
internal file store, then inject a markdown image referencing the
served URL into the assistant message via a `message` event.
Surface a generated image in the chat using Open WebUI's canonical
pattern: upload the bytes via the internal file store, then emit a
`files` event referencing the served URL. This is the same path Open
WebUI's own image-generation code uses (utils/middleware.py ~1325).
We deliberately don't use the `files` event (Open WebUI's own
image-gen path). Open WebUI 0.9.x added chat-history virtualization
that unmounts off-screen messages and reconstructs them from
persisted shape — and `files` attached to an assistant message
mid-stream by a tool don't survive that round-trip. The image
flashes in during streaming and disappears the moment the message
commits. Markdown in `message.content` is part of the persisted
shape, so it renders reliably on every remount.
Returns True if the image was uploaded and emitted. Returns False
if anything is missing — caller falls back to a data-URI markdown
message (same `message` event path, just inline bytes).
Returns True if the image was uploaded and emitted via the files
event. Returns False if anything is missing — caller should fall
back to a data-URI markdown message in that case.
"""
if not (_OPENWEBUI_RUNTIME and request and user_dict and event_emitter):
return False
try:
user = Users.get_user_by_id(user_dict.get("id"))
user = await Users.get_user_by_id(user_dict.get("id"))
if not user:
return False
@@ -638,23 +636,9 @@ async def _push_image_to_chat(
"get_file_content_by_id", id=file_item.id
)
# TODO(open-webui virtualization fix): once chat-history
# virtualization survives `files` events from tool calls again,
# swap back to the `files` payload below. The `files` event
# gives proper file-attachment chrome (thumbnail card + download
# button) that the markdown-image path lacks. Verify the fix by
# emitting `files`, then refreshing the page AND scrolling the
# message off-screen and back — both must keep the image
# visible. Track upstream: github.com/open-webui/open-webui
# release notes mentioning tool calls, `files` events, or
# virtualization. To restore, replace the block below with:
# await event_emitter({
# "type": "files",
# "data": {"files": [{"type": "image", "url": url}]},
# })
await event_emitter({
"type": "message",
"data": {"content": f"\n![{filename_prefix}]({url})\n"},
"type": "files",
"data": {"files": [{"type": "image", "url": url}]},
})
return True
except Exception:
@@ -1,7 +1,7 @@
"""
title: Smart Image Studio (Pipe)
author: ai-stack
version: 0.1.1
version: 0.1.2
description: Deterministic image-gen / edit / inpaint pipe — no LLM in the
loop for the routing decision. Registers as a model in the chat-model
dropdown ('Image Studio (Pipe)'). Reads the user's message + attached
@@ -303,7 +303,7 @@ def _file_dict_is_image(f: dict) -> bool:
return "image" in ftype or fname.endswith((".png", ".jpg", ".jpeg", ".webp"))
def _read_file_dict(f: dict) -> Optional[bytes]:
async def _read_file_dict(f: dict) -> Optional[bytes]:
for path_key in ("path", "filepath", "file_path"):
path = f.get(path_key)
if path:
@@ -323,7 +323,7 @@ def _read_file_dict(f: dict) -> Optional[bytes]:
if _OPENWEBUI_RUNTIME:
for fid in candidate_ids:
try:
file_model = Files.get_file_by_id(fid)
file_model = await Files.get_file_by_id(fid)
if file_model is None:
continue
path = getattr(file_model, "path", None)
@@ -361,13 +361,13 @@ async def _extract_attached_image(files, messages, metadata, session) -> Optiona
continue
for f in (msg.get("files") or []):
if isinstance(f, dict) and _file_dict_is_image(f):
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
# 3. __files__
for f in files or []:
if isinstance(f, dict) and _file_dict_is_image(f):
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
# 4. DB lookup (assistant-emitted files often only land here)
@@ -375,13 +375,13 @@ async def _extract_attached_image(files, messages, metadata, session) -> Optiona
chat_id = metadata.get("chat_id")
if chat_id:
try:
chat = Chats.get_chat_by_id(chat_id)
chat = await Chats.get_chat_by_id(chat_id)
chat_data = getattr(chat, "chat", None) if chat else None
chat_messages = (chat_data or {}).get("messages", []) if isinstance(chat_data, dict) else []
for msg in reversed(chat_messages):
for f in (msg.get("files") or []) if isinstance(msg, dict) else []:
if isinstance(f, dict) and _file_dict_is_image(f):
data = _read_file_dict(f)
data = await _read_file_dict(f)
if data is not None:
return data
except Exception:
@@ -404,7 +404,7 @@ async def _push_image_to_chat(raw, prefix, request, user_dict, metadata, event_e
if not (_OPENWEBUI_RUNTIME and request and user_dict and event_emitter):
return False
try:
user = Users.get_user_by_id(user_dict.get("id"))
user = await Users.get_user_by_id(user_dict.get("id"))
if not user:
return False
upload = UploadFile(
@@ -420,23 +420,9 @@ async def _push_image_to_chat(raw, prefix, request, user_dict, metadata, event_e
)
file_item = await result if inspect.iscoroutine(result) else result
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
# TODO(open-webui virtualization fix): once chat-history
# virtualization survives `files` events from tool calls again,
# swap back to the `files` payload below. The `files` event
# gives proper file-attachment chrome (thumbnail card + download
# button) that the markdown-image path lacks. Verify the fix by
# emitting `files`, then refreshing the page AND scrolling the
# message off-screen and back — both must keep the image
# visible. Track upstream: github.com/open-webui/open-webui
# release notes mentioning tool calls, `files` events, or
# virtualization. To restore, replace the block below with:
# await event_emitter({
# "type": "files",
# "data": {"files": [{"type": "image", "url": url}]},
# })
await event_emitter({
"type": "message",
"data": {"content": f"\n![{prefix}]({url})\n"},
"type": "files",
"data": {"files": [{"type": "image", "url": url}]},
})
return True
except Exception: