mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-11 08:57:17 +00:00
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:
parent
29541cbb52
commit
8f9506536f
10 changed files with 725 additions and 189 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -154,6 +154,13 @@
|
|||
}
|
||||
};
|
||||
|
||||
export const showAllMessages = async () => {
|
||||
if (messagesCount === null) return;
|
||||
messagesCount = null;
|
||||
buildMessages();
|
||||
await tick();
|
||||
};
|
||||
|
||||
export const scrollToTop = async () => {
|
||||
messagesCount = null;
|
||||
buildMessages();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
100
src/lib/utils/image.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue