feat(playground): add a persistent gallery to the image playground

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.
This commit is contained in:
silentoplayz 2026-07-29 23:13:00 -04:00
parent 29541cbb52
commit 8f9506536f
10 changed files with 725 additions and 189 deletions

View file

@ -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 = {

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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', {

View file

@ -154,6 +154,13 @@
}
};
export const showAllMessages = async () => {
if (messagesCount === null) return;
messagesCount = null;
buildMessages();
await tick();
};
export const scrollToTop = async () => {
messagesCount = null;
buildMessages();

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { onDestroy, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { WEBUI_BASE_URL } from '$lib/constants';
import PanzoomContainer from '$lib/components/common/PanzoomContainer.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -119,9 +121,35 @@
src.startsWith('http://') ||
src.startsWith('https://')
) {
// Handle remote URLs
fetch(src)
.then((response) => response.blob())
const ownOrigins = new Set([window.location.origin]);
if (WEBUI_BASE_URL) {
try {
ownOrigins.add(new URL(WEBUI_BASE_URL, window.location.origin).origin);
} catch {
}
}
let isOwnBackend = src.startsWith('/');
if (!isOwnBackend) {
try {
isOwnBackend = ownOrigins.has(new URL(src).origin);
} catch {
isOwnBackend = false;
}
}
fetch(
src,
isOwnBackend && localStorage.token
? { headers: { Authorization: `Bearer ${localStorage.token}` } }
: undefined
)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
return response.blob();
})
.then((blob) => {
// detect the MIME type from the blob
const mimeType = blob.type || 'image/png';
@ -140,6 +168,7 @@
})
.catch((error) => {
console.error('Error downloading remote image:', error);
toast.error($i18n.t('Failed to download image'));
});
return;
}

View file

@ -1,25 +1,63 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { onMount, onDestroy, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { imageGenerations, imageEdits } from '$lib/apis/images';
import { config, user } from '$lib/stores';
import { imageGenerations, imageEdits, getGeneratedImages } from '$lib/apis/images';
import { deleteFileById } from '$lib/apis/files';
import { chatExists } from '$lib/apis/chats';
import {
resolveFileUrl,
readFileAsDataUrl,
copyImageToClipboard,
downloadImage,
getFileModel,
getFileSize
} from '$lib/utils/image';
import type { GalleryImageFile } from '$lib/utils/image';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Download from '$lib/components/icons/Download.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ImagePreview from '$lib/components/common/ImagePreview.svelte';
const i18n = getContext('i18n');
let loaded = false;
let loading = false;
let prompt = '';
let sourceImages: string[] = [];
let generatedImages: { url: string }[] = [];
let previewSrc = '';
let showPreview = false;
const openPreview = (url: string) => {
previewSrc = resolveFileUrl(url);
showPreview = true;
};
let galleryImages: GalleryImageFile[] = [];
let galleryLoading = false;
let galleryHasMore = true;
let galleryOffset = 0;
const GALLERY_PAGE_SIZE = 20;
let validChatIds: Set<string> = new Set();
let showDeleteConfirm = false;
let deleteTargetId: string | null = null;
let promptTextareaElement: HTMLTextAreaElement;
let fileInputElement: HTMLInputElement;
const resizePromptTextarea = () => {
if (promptTextareaElement) {
promptTextareaElement.style.height = '';
@ -27,36 +65,23 @@
}
};
const handleFileUpload = (event: Event) => {
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
Array.from(input.files).forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
sourceImages = [...sourceImages, e.target.result as string];
}
};
reader.readAsDataURL(file);
});
const urls = await Promise.all(
Array.from(input.files).map((file) => readFileAsDataUrl(file))
);
sourceImages = [...sourceImages, ...urls];
}
};
const handleDrop = (event: DragEvent) => {
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (files) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
sourceImages = [...sourceImages, e.target.result as string];
}
};
reader.readAsDataURL(file);
}
});
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'));
const urls = await Promise.all(imageFiles.map((file) => readFileAsDataUrl(file)));
sourceImages = [...sourceImages, ...urls];
}
};
@ -74,208 +99,503 @@
try {
let result;
if (sourceImages.length > 0) {
console.log('Calling imageEdits with', sourceImages.length, 'images');
result = await imageEdits(
localStorage.token,
sourceImages.length === 1 ? sourceImages[0] : sourceImages,
prompt
);
} else {
console.log('Calling imageGenerations');
result = await imageGenerations(localStorage.token, prompt);
}
console.log('Result:', result);
if (result) {
generatedImages = [...result, ...generatedImages];
await refreshGallery();
}
} catch (error) {
console.error('Image generation/edit error:', error);
toast.error(`${error}`);
const errorMessage = typeof error === 'string' ? error : `${error}`;
toast.error(errorMessage);
} finally {
loading = false;
}
};
const downloadImage = async (url: string, index: number) => {
const chatLinkFor = (file: GalleryImageFile) => {
const data = file.meta?.data as Record<string, unknown> | undefined;
const chatId = data?.chat_id as string;
const messageId = data?.message_id as string;
return messageId
? `/c/${chatId}?message=${encodeURIComponent(messageId)}&image=${encodeURIComponent(file.id)}`
: `/c/${chatId}`;
};
const syncChatLinks = async (files: GalleryImageFile[]) => {
const chatIds = new Set<string>();
for (const f of files) {
const chatId = (f.meta?.data as Record<string, unknown>)?.chat_id as string;
if (chatId && !validChatIds.has(chatId)) {
chatIds.add(chatId);
}
}
if (chatIds.size === 0) return;
await Promise.all(
[...chatIds].map(async (id) => {
if (await chatExists(localStorage.token, id)) {
validChatIds.add(id);
} else {
validChatIds.delete(id);
}
})
);
validChatIds = validChatIds;
};
const loadGalleryPage = async () => {
galleryLoading = true;
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `image-${Date.now()}-${index}.png`;
a.click();
URL.revokeObjectURL(blobUrl);
const files = await getGeneratedImages(localStorage.token, galleryOffset, GALLERY_PAGE_SIZE);
if (files && files.length > 0) {
galleryOffset += files.length;
const existingIds = new Set(galleryImages.map((f) => f.id));
galleryImages = [
...galleryImages,
...files.filter((f: GalleryImageFile) => !existingIds.has(f.id))
];
galleryHasMore = files.length === GALLERY_PAGE_SIZE;
await syncChatLinks(files);
} else {
galleryHasMore = false;
}
} catch (error) {
galleryHasMore = false;
toast.error(`${error}`);
}
galleryLoading = false;
};
const refreshGallery = async () => {
if (galleryLoading || !loaded) return;
try {
const latestFiles = await getGeneratedImages(localStorage.token, 0, GALLERY_PAGE_SIZE);
if (!latestFiles || latestFiles.length === 0) return;
const existingIds = new Set(galleryImages.map((f) => f.id));
const newFiles = latestFiles.filter((f: GalleryImageFile) => !existingIds.has(f.id));
if (newFiles.length > 0) {
galleryImages = [...newFiles, ...galleryImages];
galleryOffset += newFiles.length;
}
await syncChatLinks(galleryImages);
} catch {
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshGallery();
}
};
const copyGalleryImage = async (url: string) => {
try {
await copyImageToClipboard(url, localStorage.token);
toast.success($i18n.t('Image copied to clipboard'));
} catch (error) {
console.error('Failed to copy image:', error);
toast.error($i18n.t('Failed to copy image'));
}
};
const downloadGalleryImage = async (url: string, fileId: string) => {
try {
await downloadImage(url, `generated-image-${fileId}.png`, localStorage.token);
} catch (error) {
console.error('Failed to download image:', error);
toast.error($i18n.t('Failed to download image'));
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
return;
const deleteGalleryImage = async (fileId: string) => {
try {
await deleteFileById(localStorage.token, fileId);
galleryImages = galleryImages.filter((f) => f.id !== fileId);
galleryOffset = Math.max(0, galleryOffset - 1);
toast.success($i18n.t('Image deleted'));
if (galleryImages.length === 0 && galleryHasMore) {
galleryOffset = 0;
await loadGalleryPage();
}
} catch {
toast.error($i18n.t('Failed to delete image'));
}
deleteTargetId = null;
};
onMount(async () => {
await loadGalleryPage();
loaded = true;
await tick();
promptTextareaElement?.focus();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<div class=" flex flex-col h-full px-2.5">
<!-- Results Area -->
<div
class=" pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="images-container"
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
{#if generatedImages.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{#each generatedImages as image, index}
<button
class="relative group cursor-pointer"
on:click={() => downloadImage(image.url, index)}
>
<img
src={image.url}
alt=""
class="w-full aspect-square object-cover rounded-lg border border-gray-100/30 dark:border-gray-850/30"
/>
<div
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition rounded-lg flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>
</button>
{/each}
</div>
{:else}
<div
class="h-full flex items-center justify-center text-gray-400 dark:text-gray-600 text-sm"
>
{$i18n.t('Generated images will appear here')}
</div>
{/if}
<div class="flex flex-col h-full px-2.5">
{#if !$config?.features?.enable_image_generation}
<div class="h-full flex flex-col items-center justify-center gap-3">
<div class="p-4 rounded-full bg-gray-50 dark:bg-gray-850">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-8 text-gray-400 dark:text-gray-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
</div>
<div class="text-center">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
{$i18n.t('Image generation is not enabled')}
</p>
<p class="text-xs text-gray-400 dark:text-gray-600 mt-1">
{$i18n.t('Contact your administrator to enable image generation')}
</p>
</div>
</div>
</div>
<!-- Input Area -->
<div class="pb-3">
{:else}
<div
class="border border-gray-100/30 dark:border-gray-850/30 w-full px-3 py-2.5 rounded-xl"
class="pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="images-container"
>
<!-- Source Images -->
{#if sourceImages.length > 0}
<div class="flex flex-wrap gap-2 mb-2">
{#each sourceImages as image, index}
<div class=" relative group">
<div class="relative flex items-center">
<img src={image} alt="" class="size-10 rounded-xl object-cover" />
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-white text-black border border-white rounded-full hover-reveal transition"
type="button"
aria-label={$i18n.t('Remove image')}
on:click={() => removeImage(index)}
<div class="h-full w-full flex flex-col">
<div class="flex-1 p-1">
{#if galleryImages.length > 0}
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each galleryImages as file (file.id)}
{@const fileUrl = `/api/v1/files/${file.id}/content`}
{@const filePrompt = file.meta?.data?.prompt}
{@const fileSize = getFileSize(file.meta?.data)}
{@const fileModel = getFileModel(file.meta?.data)}
<div
class="relative group cursor-pointer"
on:click={() => openPreview(fileUrl)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPreview(fileUrl);
}
}}
role="button"
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="size-4"
<img
src={resolveFileUrl(fileUrl)}
alt=""
class="w-full aspect-square object-cover rounded-xl border border-gray-100/30 dark:border-gray-850/30 transition"
loading="lazy"
/>
{#if fileModel || fileSize}
<div
class="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1 pointer-events-none"
>
{#if fileModel}
<span
class="min-w-0 truncate text-[10px] text-white/90 bg-black/50 backdrop-blur-sm rounded px-1.5 py-0.5 font-medium"
title={fileModel}
>
{fileModel}
</span>
{/if}
{#if fileSize}
<span
class="shrink-0 whitespace-nowrap text-[10px] text-white/90 bg-black/50 backdrop-blur-sm rounded px-1.5 py-0.5 font-medium"
>
{fileSize}
</span>
{/if}
</div>
{/if}
{#if filePrompt}
<div
class="absolute top-0 left-0 right-0 p-2 bg-gradient-to-b from-black/60 to-transparent rounded-t-xl opacity-0 group-hover:opacity-100 transition-opacity"
>
<p
class="text-[11px] text-white/90 line-clamp-2 leading-tight"
title={filePrompt}
>
{filePrompt}
</p>
</div>
{/if}
<div
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity rounded-xl flex items-end justify-center pb-3"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<div
class="flex items-center gap-1 bg-black/60 backdrop-blur-sm rounded-lg px-1.5 py-1"
>
{#if file.meta?.data?.chat_id && validChatIds.has(file.meta.data.chat_id)}
<Tooltip content={$i18n.t('Open in Chat')} placement="top">
<button
class="p-1.5 hover:bg-white/20 rounded-md transition-colors"
type="button"
aria-label={$i18n.t('Open in Chat')}
on:click|stopPropagation={() => goto(chatLinkFor(file))}
>
<ChatBubbleOval className="size-4 text-white" strokeWidth="2" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Copy')} placement="top">
<button
class="p-1.5 hover:bg-white/20 rounded-md transition-colors"
type="button"
aria-label={$i18n.t('Copy')}
on:click|stopPropagation={() => copyGalleryImage(fileUrl)}
>
<DocumentDuplicate className="size-4 text-white" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')} placement="top">
<button
class="p-1.5 hover:bg-white/20 rounded-md transition-colors"
type="button"
aria-label={$i18n.t('Download')}
on:click|stopPropagation={() =>
downloadGalleryImage(fileUrl, file.id)}
>
<Download className="size-4 text-white" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')} placement="top">
<button
class="p-1.5 hover:bg-red-500/30 rounded-md transition-colors"
type="button"
aria-label={$i18n.t('Delete')}
on:click|stopPropagation={() => {
deleteTargetId = file.id;
showDeleteConfirm = true;
}}
>
<GarbageBin className="size-4 text-white" strokeWidth="2" />
</button>
</Tooltip>
</div>
</div>
</div>
{/each}
</div>
{#if galleryHasMore}
<div class="flex justify-center mt-4">
<button
class="px-4 py-2 text-sm text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition-colors flex items-center gap-2"
type="button"
on:click={loadGalleryPage}
disabled={galleryLoading}
>
{#if galleryLoading}
<Spinner className="size-4" />
{/if}
{$i18n.t('Load more')}
</button>
</div>
{/if}
{:else if galleryLoading}
<div class="flex justify-center py-8">
<Spinner className="size-6" />
</div>
{/each}
</div>
{/if}
<!-- Prompt Textarea -->
<div class="py-0.5">
<textarea
bind:this={promptTextareaElement}
bind:value={prompt}
class=" w-full h-full bg-transparent resize-none outline-hidden text-sm"
placeholder={sourceImages.length > 0
? $i18n.t('Describe the edit...')
: $i18n.t('Describe the image...')}
on:input={resizePromptTextarea}
on:focus={resizePromptTextarea}
on:keydown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !loading) {
e.preventDefault();
submitHandler();
}
}}
rows="2"
/>
</div>
<!-- Actions -->
<div class="flex justify-between items-center gap-2 mt-2">
<div class="shrink-0">
<input
type="file"
accept="image/*"
multiple
class="hidden"
bind:this={fileInputElement}
on:change={handleFileUpload}
/>
<button
type="button"
class="px-3.5 py-1.5 text-sm font-normal bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
on:click={() => fileInputElement?.click()}
on:dragover|preventDefault
on:drop={handleDrop}
>
{$i18n.t('Add Image')}
</button>
</div>
<div class="flex gap-2 shrink-0">
{#if !loading}
<button
disabled={prompt.trim() === ''}
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
on:click={submitHandler}
>
{$i18n.t('Run')}
</button>
{:else}
<button
class="px-3.5 py-1.5 text-sm font-normal bg-gray-300 text-black transition rounded-lg flex items-center gap-2"
disabled
>
<Spinner className="size-4" />
{$i18n.t('Running...')}
</button>
<div class="h-full flex flex-col items-center justify-center gap-4 select-none">
<div class="p-4 rounded-2xl bg-gray-50 dark:bg-gray-850">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-10 text-gray-300 dark:text-gray-700"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</div>
<div class="text-center">
<p class="text-sm font-medium text-gray-500 dark:text-gray-500">
{$i18n.t('Create something amazing')}
</p>
<p class="text-xs text-gray-400 dark:text-gray-600 mt-1">
{$i18n.t('Describe an image and bring your vision to life')}
</p>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="pb-3">
<div
class="border border-gray-100/30 dark:border-gray-850/30 w-full rounded-xl overflow-hidden"
>
<div class="px-3 py-2.5">
{#if sourceImages.length > 0}
<div class="flex flex-wrap gap-2 mb-2">
{#each sourceImages as image, index}
<div class="relative group">
<div class="relative flex items-center">
<img src={image} alt="" class="size-10 rounded-xl object-cover" />
</div>
<div class="absolute -top-1 -right-1">
<button
class="bg-white text-black border border-white rounded-full hover-reveal transition"
type="button"
aria-label={$i18n.t('Remove image')}
on:click={() => removeImage(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="size-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
{/if}
<div class="py-0.5">
<textarea
bind:this={promptTextareaElement}
bind:value={prompt}
class="w-full h-full bg-transparent resize-none outline-hidden text-sm"
placeholder={sourceImages.length > 0
? $i18n.t('Describe the edit...')
: $i18n.t('Describe the image...')}
on:input={resizePromptTextarea}
on:focus={resizePromptTextarea}
on:paste={async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
const dataUrl = await readFileAsDataUrl(file);
sourceImages = [...sourceImages, dataUrl];
}
}
}}
on:keydown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !loading) {
e.preventDefault();
submitHandler();
}
}}
rows="2"
/>
</div>
<div class="flex justify-between items-center gap-2 mt-2">
<div class="flex items-center gap-2 shrink-0">
<input
type="file"
accept="image/*"
multiple
class="hidden"
bind:this={fileInputElement}
on:change={handleFileUpload}
/>
<button
type="button"
class="px-3.5 py-1.5 text-sm font-normal bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
on:click={() => fileInputElement?.click()}
on:dragover|preventDefault
on:drop={handleDrop}
>
{$i18n.t('Add Image')}
</button>
</div>
<div class="flex gap-2 shrink-0">
{#if !loading}
<button
disabled={prompt.trim() === ''}
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
on:click={submitHandler}
>
{$i18n.t('Generate')}
</button>
{:else}
<button
class="px-3.5 py-1.5 text-sm font-normal bg-gray-300 text-black transition rounded-lg flex items-center gap-2"
disabled
>
<Spinner className="size-4" />
{$i18n.t('Generating...')}
</button>
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<ImagePreview bind:show={showPreview} src={previewSrc} alt="" />
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Image')}
on:confirm={() => {
if (deleteTargetId) {
deleteGalleryImage(deleteTargetId);
}
}}
>
<div class="text-sm text-gray-500">
{$i18n.t('Are you sure you want to delete this image? This action cannot be undone.')}
</div>
</ConfirmDialog>

View file

@ -269,6 +269,7 @@
"Are you sure you want to delete this channel?": "",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
"Are you sure you want to delete this directory?": "",
"Are you sure you want to delete this image? This action cannot be undone.": "",
"Are you sure you want to delete this memory? This action cannot be undone.": "",
"Are you sure you want to delete this message?": "",
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
@ -600,6 +601,7 @@
"Connections settings updated": "",
"Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort.": "",
"Contact Admin for WebUI Access": "",
"Contact your administrator to enable image generation": "",
"Content": "",
"Content Extraction": "",
"Content Extraction Engine": "",
@ -674,6 +676,7 @@
"Create new secret key": "",
"Create Prompt": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create something amazing": "",
"Create your first note from the Create menu.": "",
"Create, copy, or rotate your API key.": "",
"Created": "",
@ -755,6 +758,7 @@
"Delete File": "",
"Delete folder?": "",
"Delete function?": "",
"Delete Image": "",
"Delete Memory?": "",
"Delete Message": "",
"Delete message?": "",
@ -774,6 +778,7 @@
"Deleted User": "",
"Deployment names are required for Azure OpenAI": "",
"Desc": "",
"Describe an image and bring your vision to life": "",
"Describe the edit...": "",
"Describe the image...": "",
"Describe what changed...": "",
@ -1164,10 +1169,12 @@
"Failed to compare files.": "",
"Failed to connect to {{URL}} OpenAPI tool server": "",
"Failed to connect to {{URL}} terminal server": "",
"Failed to copy image": "",
"Failed to copy link": "",
"Failed to create API Key.": "",
"Failed to create chat": "",
"Failed to delete calendar": "",
"Failed to delete image": "",
"Failed to delete note": "",
"Failed to delete webhook": "",
"Failed to disconnect": "",
@ -1316,7 +1323,6 @@
"Generate suggested follow-up prompts after responses.": "",
"Generate tags for chats automatically.": "",
"Generated Image": "Generated Image",
"Generated images will appear here": "",
"Generating search query": "",
"Generating...": "",
"Generation": "",
@ -1397,10 +1403,13 @@
"Image Compression": "",
"Image Compression Height": "",
"Image Compression Width": "",
"Image copied to clipboard": "",
"Image deleted": "",
"Image Edit": "",
"Image Edit Engine": "",
"Image Generation": "",
"Image Generation Engine": "",
"Image generation is not enabled": "",
"Image Max Compression Size": "",
"Image Max Compression Size height": "",
"Image Max Compression Size width": "",
@ -1571,6 +1580,7 @@
"Live": "",
"llama.cpp": "",
"LLMs can make mistakes. Verify important information.": "",
"Load more": "",
"Loaded": "",
"Loader": "",
"Loading Kokoro.js...": "",
@ -1938,6 +1948,7 @@
"Open create menu": "",
"Open detail blocks by default.": "",
"Open file": "",
"Open in Chat": "",
"Open in full screen": "",
"Open in new tab": "",
"Open link": "",

100
src/lib/utils/image.ts Normal file
View file

@ -0,0 +1,100 @@
import { WEBUI_BASE_URL } from '$lib/constants';
export interface GalleryImageFile {
id: string;
filename: string;
meta: {
name?: string;
content_type?: string;
size?: number;
data?: Record<string, unknown>;
};
created_at: number;
}
export const resolveFileUrl = (url: string): string => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
return url;
}
return `${WEBUI_BASE_URL}${url}`;
};
export const readFileAsDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const fetchImageBlob = async (url: string, token: string): Promise<Blob> => {
const response = await fetch(resolveFileUrl(url), {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
return await response.blob();
};
export const copyImageToClipboard = async (url: string, token: string): Promise<void> => {
const blob = await fetchImageBlob(url, token);
const pngBlob =
blob.type === 'image/png'
? blob
: await new Promise<Blob>((resolve, reject) => {
const objectUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d')?.drawImage(img, 0, 0);
canvas.toBlob((b) => {
URL.revokeObjectURL(objectUrl);
resolve(b || blob);
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Failed to decode image for clipboard'));
};
img.src = objectUrl;
});
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
};
export const downloadImage = async (
url: string,
filename: string,
token: string
): Promise<void> => {
const blob = await fetchImageBlob(url, token);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename || 'generated-image.png';
a.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
};
export const getFileModel = (data: Record<string, unknown> | undefined): string | null => {
if (!data) return null;
if (data.model) return data.model as string;
const info = data.info as string | undefined;
if (info) {
const match = info.match(/Model:\s*([^,\n]+)/i);
if (match) return match[1].trim();
}
return null;
};
export const getFileSize = (data: Record<string, unknown> | undefined): string | null => {
if (!data) return null;
if (typeof data.size === 'string') return data.size;
if (data.width && data.height) return `${data.width}x${data.height}`;
return null;
};