From c07e962cae3a31734039769dbf20512f7db02516 Mon Sep 17 00:00:00 2001 From: William Gill Date: Sun, 26 Apr 2026 06:16:02 -0500 Subject: [PATCH] Image tools: migrate to OWUI 0.9.0 async model accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../openwebui-tools/smart_image_gen.py | 66 +++++++------------ .../openwebui-tools/smart_image_pipe.py | 34 +++------- 2 files changed, 35 insertions(+), 65 deletions(-) diff --git a/deployments/ai-stack/openwebui-tools/smart_image_gen.py b/deployments/ai-stack/openwebui-tools/smart_image_gen.py index 4a65716..2b94ea2 100644 --- a/deployments/ai-stack/openwebui-tools/smart_image_gen.py +++ b/deployments/ai-stack/openwebui-tools/smart_image_gen.py @@ -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//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: diff --git a/deployments/ai-stack/openwebui-tools/smart_image_pipe.py b/deployments/ai-stack/openwebui-tools/smart_image_pipe.py index 35f3608..02a45dc 100644 --- a/deployments/ai-stack/openwebui-tools/smart_image_pipe.py +++ b/deployments/ai-stack/openwebui-tools/smart_image_pipe.py @@ -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: