From 8f9506536f49375467d13a713162d13420890951 Mon Sep 17 00:00:00 2001 From: silentoplayz Date: Wed, 29 Jul 2026 23:13:00 -0400 Subject: [PATCH] feat(playground): add a persistent gallery to the image playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image playground discarded results on reload, showing only images from the current session. This adds a persistent, paginated gallery of every generated image, backed by the existing files-search API rather than a new endpoint. - Gallery grid with lazy-loaded thumbnails, model and size labels, and the originating prompt on hover. Clicking an image opens the existing ImagePreview lightbox. - Per-image actions: Open in Chat (only when the source chat still exists), Copy to clipboard, Download, and Delete with confirmation. - New images appear immediately after generating, and images generated elsewhere (e.g. from a chat) are picked up when the tab regains focus. Both paths read the stored metadata rather than synthesising it, so the labels always reflect what the engine actually used. - Stores model and size in generated-file metadata so the gallery can label images, and threads chat_id/message_id through the built-in generate_image and edit_image tools so gallery images can link back to their chat. Also fixes the ImagePreview download button, which fetched the image with no credentials. Backend file URLs require authentication, so the request returned 401 — and because the response status was never checked, the JSON error body was saved as the downloaded file. It now sends the bearer token when the target is this deployment's own backend (which in development is a different origin from the page, so a same-origin check is not sufficient), never to a third-party host, and refuses to save a non-OK response. This affected generated images in chat as well as the gallery. Shared image helpers live in src/lib/utils/image.ts. --- backend/open_webui/routers/images.py | 3 + backend/open_webui/tools/builtin.py | 8 + src/lib/apis/chats/index.ts | 13 + src/lib/apis/images/index.ts | 12 + src/lib/components/chat/Chat.svelte | 35 +- src/lib/components/chat/Messages.svelte | 7 + src/lib/components/common/ImagePreview.svelte | 35 +- src/lib/components/playground/Images.svelte | 688 +++++++++++++----- src/lib/i18n/locales/en-US/translation.json | 13 +- src/lib/utils/image.ts | 100 +++ 10 files changed, 725 insertions(+), 189 deletions(-) create mode 100644 src/lib/utils/image.ts diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index a15dd91498..83ecdc407e 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -613,6 +613,9 @@ async def image_generations( model = await get_image_model(request) + metadata['model'] = model + metadata['size'] = size + try: if image_config.IMAGE_GENERATION_ENGINE == 'openai': headers = { diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 136d2001dc..ed52f779cd 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -388,6 +388,10 @@ async def generate_image( images = await image_generations( request=__request__, form_data=CreateImageForm(prompt=prompt), + metadata={ + 'chat_id': __chat_id__, + 'message_id': __message_id__, + }, user=user, ) @@ -456,6 +460,10 @@ async def edit_image( images = await image_edits( request=__request__, form_data=EditImageForm(prompt=prompt, image=image_urls), + metadata={ + 'chat_id': __chat_id__, + 'message_id': __message_id__, + }, user=user, ) diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index a659fd9b08..92e920536e 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -785,6 +785,19 @@ export const getChatById = async (token: string, id: string) => { return res; }; +export const chatExists = async (token: string, id: string) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }).catch(() => null); + + return res?.ok ?? false; +}; + export const getChatByShareId = async (token: string, share_id: string) => { let error = null; diff --git a/src/lib/apis/images/index.ts b/src/lib/apis/images/index.ts index 5d87c13a18..8877213d67 100644 --- a/src/lib/apis/images/index.ts +++ b/src/lib/apis/images/index.ts @@ -1,4 +1,16 @@ import { IMAGES_API_BASE_URL } from '$lib/constants'; +import { searchFiles } from '$lib/apis/files'; + +export const getGeneratedImages = async (token: string, skip: number = 0, limit: number = 20) => { + try { + return await searchFiles(token, 'generated-image*', skip, limit); + } catch (error) { + if (typeof error === 'string' && error.includes('No files found')) { + return []; + } + throw error; + } +}; export const getConfig = async (token: string = '') => { let error = null; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 686613cca9..33fffb63ec 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -148,6 +148,7 @@ let messagesRef: Messages | undefined; let autoScroll = true; + let focusedMessageOnLoad = false; let isNearTop = true; let processing = ''; let messagesContainerElement: HTMLDivElement; @@ -586,7 +587,9 @@ await tick(); loading = false; noteChatDebug('embedded chat loading false'); - window.setTimeout(() => scrollToBottom(), 0); + if (!focusedMessageOnLoad) { + window.setTimeout(() => scrollToBottom(), 0); + } await tick(); @@ -2007,6 +2010,7 @@ const loadChat = async () => { noteChatDebug('loadChat start'); + focusedMessageOnLoad = false; // chatIdProp is empty for chats started from the home page (URL set via replaceState) chatId.set(chatIdProp || $chatId); noteChatDebug('loadChat set active chat id'); @@ -2149,6 +2153,35 @@ await tick(); + const focusMessageId = $page.url.searchParams.get('message'); + if (focusMessageId && history.messages[focusMessageId]) { + const focusFileId = $page.url.searchParams.get('image'); + await showMessage(history.messages[focusMessageId], false, false); + autoScroll = false; + await messagesRef?.showAllMessages(); + await tick(); + + const scrollToFocus = () => { + const messageElement = document.getElementById(`message-${focusMessageId}`); + const target = + (focusFileId && + messageElement?.querySelector(`img[src*="${CSS.escape(focusFileId)}"]`)) || + messageElement; + target?.scrollIntoView({ behavior: 'auto', block: 'center' }); + return target; + }; + + const target = scrollToFocus(); + requestAnimationFrame(() => { + scrollToFocus(); + requestAnimationFrame(() => scrollToFocus()); + }); + if (target instanceof HTMLImageElement && !target.complete) { + target.addEventListener('load', () => scrollToFocus(), { once: true }); + } + focusedMessageOnLoad = true; + } + return true; } else { console.warn('[note-chat] chat response missing chat payload', { diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 2f4ad5fec9..23a2e00e32 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -154,6 +154,13 @@ } }; + export const showAllMessages = async () => { + if (messagesCount === null) return; + messagesCount = null; + buildMessages(); + await tick(); + }; + export const scrollToTop = async () => { messagesCount = null; buildMessages(); diff --git a/src/lib/components/common/ImagePreview.svelte b/src/lib/components/common/ImagePreview.svelte index d1186562cf..a47b3d379d 100644 --- a/src/lib/components/common/ImagePreview.svelte +++ b/src/lib/components/common/ImagePreview.svelte @@ -1,9 +1,11 @@ -
+ +
-
- -