This commit is contained in:
Timothy Jaeryang Baek 2026-09-15 22:47:03 -04:00
parent a096961a31
commit 66addbd6b4
15 changed files with 430 additions and 91 deletions

View file

@ -9,7 +9,7 @@ from typing import Optional
import bcrypt
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
from open_webui.utils.validate import validate_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.exc import IntegrityError
@ -87,7 +87,7 @@ class SignupForm(BaseModel):
@classmethod
def check_profile_image_url(cls, v: str | None) -> str | None:
if v is not None:
return validate_profile_image_url(v)
return validate_image_url(v)
return v

View file

@ -10,7 +10,7 @@ from open_webui.models.access_grants import (
)
from open_webui.models.groups import Groups
from open_webui.models.users import User
from open_webui.utils.validate import validate_profile_image_url
from open_webui.utils.validate import validate_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import (
JSON,
@ -253,7 +253,7 @@ class ChannelWebhookForm(BaseModel):
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
return validate_profile_image_url(v)
return validate_image_url(v)
class ChannelTable:

View file

@ -10,8 +10,8 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from open_webui.utils.validate import validate_image_url
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -75,6 +75,7 @@ class ModelMeta(BaseModel):
"""Metadata for a workspace model entry (profile, description, tags, capabilities)."""
profile_image_url: str | None = None
background_image_url: str | None = None
description: str | None = Field(default=None, description='User-facing description of the model.')
i18n: dict[str, Any] | None = None
capabilities: dict | None = None
@ -82,14 +83,16 @@ class ModelMeta(BaseModel):
model_config = ConfigDict(extra='allow')
@field_validator('profile_image_url', mode='before')
@field_validator('profile_image_url', 'background_image_url', mode='before')
@classmethod
def check_profile_image_url(cls, v: str | None) -> str | None:
def check_image_url(cls, v: str | None, info: ValidationInfo) -> str | None:
if v is None:
return v
try:
return validate_profile_image_url(v)
return validate_image_url(v, file_only=info.field_name == 'background_image_url')
except ValueError:
if info.field_name == 'background_image_url':
raise
return None
@field_validator('knowledge', mode='before')
@ -239,11 +242,14 @@ class ModelsTable:
return models
async def get_models(
self, writable_by_user_id: str | None = None, db: AsyncSession | None = None
self, writable_by_user_id: str | None = None, db: AsyncSession | None = None, ids: list[str] | None = None
) -> list[ModelUserResponse]:
async with get_async_db_context(db) as db:
stmt = select(Model).filter(Model.base_model_id != None)
if ids is not None:
stmt = stmt.filter(Model.id.in_(ids))
if writable_by_user_id:
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db)
@ -281,13 +287,15 @@ class ModelsTable:
)
return models
async def get_model_owners_attaching_file(self, file_id: str, db: AsyncSession | None = None) -> dict[str, str]:
"""Map of model id to owner id for workspace models whose knowledge attaches this file."""
async def get_model_owner_ids_by_file_id(
self, file_id: str, db: AsyncSession | None = None, include_background: bool = False
) -> dict[str, str]:
"""Return model IDs mapped to owner IDs for models referencing the file."""
async with get_async_db_context(db) as db:
# File ids are server-generated uuids, so the text match can only over-match.
result = await db.execute(
select(Model.id, Model.user_id, Model.meta).filter(
Model.base_model_id.is_not(None), cast(Model.meta, String).like(f'%"{file_id}"%')
Model.base_model_id.is_not(None), cast(Model.meta, String).like(f'%{file_id}%')
)
)
return {
@ -297,6 +305,7 @@ class ModelsTable:
isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file_id
for item in meta.get('knowledge') or []
)
or (include_background and meta.get('background_image_url') == f'/api/v1/files/{file_id}/content')
}
@staticmethod

View file

@ -8,7 +8,7 @@ from typing import Literal, Optional
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from open_webui.utils.validate import validate_image_url
from pydantic import (
BaseModel,
ConfigDict,
@ -277,7 +277,7 @@ class UpdateProfileForm(BaseModel):
@field_validator('profile_image_url')
@classmethod
def check_profile_image_url(cls, v: str) -> str:
return validate_profile_image_url(v)
return validate_image_url(v)
class UserGroupIdsModel(UserModel):
@ -367,7 +367,7 @@ class UserUpdateForm(BaseModel):
def check_profile_image_url(cls, v: str | None) -> str | None:
if v is None:
return v
return validate_profile_image_url(v)
return validate_image_url(v)
class UsersTable:
@ -383,7 +383,7 @@ class UsersTable:
db: AsyncSession | None = None,
) -> UserModel | None:
try:
profile_image_url = validate_profile_image_url(profile_image_url)
profile_image_url = validate_image_url(profile_image_url)
except ValueError:
profile_image_url = '/user.png'
@ -754,7 +754,7 @@ class UsersTable:
db: AsyncSession | None = None,
) -> UserModel | None:
try:
profile_image_url = validate_profile_image_url(profile_image_url)
profile_image_url = validate_image_url(profile_image_url)
except ValueError:
profile_image_url = '/user.png'

View file

@ -12,6 +12,7 @@ from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
Response,
status,
@ -28,6 +29,7 @@ from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.files import Files
from open_webui.models.groups import Groups
from open_webui.models.models import (
ModelAccessListResponse,
@ -40,11 +42,13 @@ from open_webui.models.models import (
ModelResponse,
Models,
)
from open_webui.storage.provider import Storage
from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_variables import get_chat_variables_schema
from open_webui.utils.models import get_all_models
from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@ -96,6 +100,27 @@ def is_valid_model_id(model_id: str) -> bool:
return model_id and len(model_id) <= 256 and not any(char.isspace() for char in model_id)
async def _verify_background_image(url: str | None, user, db, previous_url: str | None = None) -> None:
if not url or url == previous_url:
return
file_id = url.split('/')[-2]
file = await Files.get_file_by_id(file_id, db=db)
if not file or not (
user.role == 'admin' or file.user_id == user.id or await has_access_to_file(file_id, 'read', user, db=db)
):
raise HTTPException(status_code=403, detail='Background image is not accessible.')
try:
path = await asyncio.to_thread(Storage.get_file, file.path)
with open(path, 'rb') as image:
data = await asyncio.to_thread(image.read, BACKGROUND_IMAGE_MAX_BYTES + 1)
content_type = await asyncio.to_thread(validate_background_image, data)
except (ValueError, OSError) as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if (file.meta or {}).get('content_type') != content_type:
if not await Files.update_file_metadata_by_id(file_id, {'content_type': content_type}, db=db):
raise HTTPException(status_code=500, detail='Could not validate background image.')
async def _verify_knowledge_file_access(
knowledge_items: list | None,
user,
@ -315,6 +340,8 @@ async def create_new_model(
db,
)
await _verify_background_image(form_data.meta.background_image_url, user, db)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
@ -345,9 +372,14 @@ async def create_new_model(
############################
@router.get('/export', response_model=list[ModelModel])
class ModelExportResponse(ModelModel):
background_image_data: str | None = None
@router.get('/export', response_model=list[ModelExportResponse])
async def export_models(
request: Request,
ids: list[str] | None = Query(None),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -363,9 +395,36 @@ async def export_models(
)
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
return await Models.get_models(db=db)
models = await Models.get_models(db=db, ids=ids)
else:
return await Models.get_models(writable_by_user_id=user.id, db=db)
models = await Models.get_models(writable_by_user_id=user.id, db=db, ids=ids)
if ids is not None:
requested = set(ids)
if requested != {model.id for model in models}:
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
exported = []
for model in models:
data = model.model_dump()
url = model.meta.background_image_url
if url:
try:
file = await Files.get_file_by_id(url.split('/')[-2], db=db)
if not file:
raise ValueError('Image file is missing')
path = await asyncio.to_thread(Storage.get_file, file.path)
with open(path, 'rb') as image:
image_data = await asyncio.to_thread(image.read, BACKGROUND_IMAGE_MAX_BYTES + 1)
content_type = await asyncio.to_thread(validate_background_image, image_data)
data['background_image_data'] = f'data:{content_type};base64,' + base64.b64encode(image_data).decode(
'ascii'
)
data['meta']['background_image_url'] = None
except Exception as error:
raise HTTPException(
status_code=400, detail=f'Could not export background for model {model.id}.'
) from error
exported.append(data)
return exported
############################
@ -495,7 +554,7 @@ async def import_models(
updated_model.access_grants,
'sharing.public_models',
)
await Models.update_model_by_id(model_id, updated_model, db=db)
imported_model = updated_model
else:
# Insert new model
model_data['meta'] = model_data.get('meta', {})
@ -539,7 +598,58 @@ async def import_models(
new_model.access_grants,
'sharing.public_models',
)
await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db)
imported_model = new_model
uploaded = None
try:
encoded = model_data.pop('background_image_data', None)
if encoded is not None:
if (
not isinstance(encoded, str)
or len(encoded) > 4 * ((BACKGROUND_IMAGE_MAX_BYTES + 2) // 3) + 64
):
raise ValueError('Background image must be at most 5 MiB.')
header, payload = encoded.split(',', 1)
image_data = base64.b64decode(payload, validate=True)
content_type = await asyncio.to_thread(validate_background_image, image_data)
if header != f'data:{content_type};base64':
raise ValueError('Invalid background image data URI.')
from fastapi import UploadFile
from open_webui.routers.files import upload_file_handler
uploaded = await upload_file_handler(
request,
file=UploadFile(
file=io.BytesIO(image_data),
filename='background.' + content_type.split('/')[1],
),
metadata=None,
process=False,
user=user,
db=db,
)
imported_model.meta.background_image_url = f'/api/v1/files/{uploaded.id}/content'
await _verify_background_image(
imported_model.meta.background_image_url,
user,
db,
existing_model.meta.background_image_url if existing_model else None,
)
saved = (
await Models.update_model_by_id(model_id, imported_model, db=db)
if existing_model
else await Models.insert_new_model(user_id=user.id, form_data=imported_model, db=db)
)
if not saved:
raise HTTPException(status_code=500, detail=f'Could not import model {model_id}.')
except Exception:
if uploaded:
try:
await Files.delete_file_by_id(uploaded.id, db=db)
await asyncio.to_thread(Storage.delete_file, uploaded.path)
except Exception:
log.exception('Could not clean up failed model background upload')
raise
imported_ids.append(model_id)
await publish_event(
@ -552,6 +662,10 @@ async def import_models(
return True
else:
raise HTTPException(status_code=400, detail='Invalid JSON format')
except HTTPException:
raise
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
except Exception as e:
log.exception(e)
raise HTTPException(status_code=500, detail=str(e))
@ -573,6 +687,14 @@ async def sync_models(
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
existing = {model.id: model for model in await Models.get_models_by_ids([m.id for m in form_data.models], db=db)}
for model in form_data.models:
previous = existing.get(model.id)
if previous and 'background_image_url' not in model.meta.model_fields_set:
model.meta.background_image_url = previous.meta.background_image_url
await _verify_background_image(
model.meta.background_image_url, user, db, previous.meta.background_image_url if previous else None
)
models = await Models.sync_models(user.id, form_data.models, db=db)
await publish_event(
request,
@ -857,6 +979,10 @@ async def update_model_by_id(
if 'profile_image_url' not in form_data.meta.model_fields_set:
form_data.meta.profile_image_url = model.meta.profile_image_url
if 'background_image_url' not in form_data.meta.model_fields_set:
form_data.meta.background_image_url = model.meta.background_image_url
await _verify_background_image(form_data.meta.background_image_url, user, db, model.meta.background_image_url)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,

View file

@ -106,7 +106,9 @@ async def has_access_to_file(
# Check if the file is directly attached to a shared workspace model (per the ownership
# note above, model write is conferred only for files the model owner owns).
model_owners = await Models.get_model_owners_attaching_file(file.id, db=db)
model_owners = await Models.get_model_owner_ids_by_file_id(
file.id, db=db, include_background=access_type == 'read'
)
if access_type != 'read':
model_owners = {model_id: owner_id for model_id, owner_id in model_owners.items() if owner_id == file.user_id}
if user.id in model_owners.values():

View file

@ -91,7 +91,7 @@ from open_webui.utils.auth import (
)
from open_webui.utils.groups import apply_default_group_assignment
from open_webui.utils.misc import parse_duration
from open_webui.utils.validate import validate_profile_image_url
from open_webui.utils.validate import validate_image_url
from starlette.responses import RedirectResponse
# Some IdPs put private params in ID token JOSE headers (CAS: client_id, CyberArk: app_id).
@ -1812,7 +1812,7 @@ class OAuthManager:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(picture).decode('utf-8')
try:
return validate_profile_image_url(f'data:{upstream_mime};base64,{base64_encoded_picture}')
return validate_image_url(f'data:{upstream_mime};base64,{base64_encoded_picture}')
except ValueError:
log.warning(
f'Rejected OAuth profile picture from {picture_url}: '

View file

@ -1,5 +1,6 @@
"""Validation utilities for user-supplied input."""
import io
import re
from urllib.parse import urlparse
@ -7,6 +8,7 @@ from open_webui.env import (
PROFILE_IMAGE_ALLOWED_MIME_TYPES,
PROFILE_IMAGE_MAX_DATA_URI_SIZE,
)
from PIL import Image
_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$')
@ -30,11 +32,14 @@ _SAFE_STATIC_PATHS = frozenset(
)
def validate_profile_image_url(url: str) -> str:
def validate_image_url(url: str, *, file_only: bool = False) -> str:
"""
Pydantic-compatible validator for profile image URLs.
Validate profile image URLs or canonical file URLs for model backgrounds.
Allowed formats:
With file_only=True, only /api/v1/files/<uuid>/content is accepted.
This checks the URL only; file access and image bytes are checked when saving.
Profile formats (the default):
- Empty string (falls back to default avatar)
- Known static-asset paths assigned by OWUI (exact match)
- The OWUI profile-image API route ``/api/v1/users/{id}/profile/image``
@ -48,15 +53,17 @@ def validate_profile_image_url(url: str) -> str:
- Scheme-relative URLs (``//host/path``)
- data URIs larger than PROFILE_IMAGE_MAX_DATA_URI_SIZE bytes
"""
if not url:
return url
if not isinstance(url, str):
raise ValueError('Invalid image URL.')
if file_only:
if re.fullmatch(r'/api/v1/files/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/content', url):
return url
raise ValueError('Invalid background image URL: must reference an internal file.')
# --- Relative paths (exact match + anchored regex only) -----------
if url in _SAFE_STATIC_PATHS:
return url
if _USER_PROFILE_IMAGE_RE.match(url):
if not url or url in _SAFE_STATIC_PATHS or _USER_PROFILE_IMAGE_RE.match(url):
return url
# --- Absolute URLs -------------------------------------------------
@ -87,3 +94,27 @@ def validate_profile_image_url(url: str) -> str:
'Invalid profile image URL: must be a known internal path, '
'an HTTP(S) URL with a host, or a data:image URI (png/jpeg/gif/webp).'
)
BACKGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024
BACKGROUND_IMAGE_MAX_PIXELS = 25_000_000
BACKGROUND_IMAGE_MIME_TYPES = {'PNG': 'image/png', 'JPEG': 'image/jpeg', 'WEBP': 'image/webp', 'GIF': 'image/gif'}
def validate_background_image(data: bytes) -> str:
if len(data) > BACKGROUND_IMAGE_MAX_BYTES:
raise ValueError('Background image must be at most 5 MiB.')
try:
with Image.open(io.BytesIO(data)) as image:
content_type = BACKGROUND_IMAGE_MIME_TYPES.get(image.format)
if not content_type:
raise ValueError('Background image must be PNG, JPEG, WebP, or GIF.')
if image.width * image.height > BACKGROUND_IMAGE_MAX_PIXELS:
raise ValueError('Background image must be at most 25 megapixels.')
image.verify()
# verify() does not decode pixels for every format.
with Image.open(io.BytesIO(data)) as image:
image.load()
except (OSError, SyntaxError, Image.DecompressionBombError) as error:
raise ValueError('Invalid background image.') from error
return content_type

View file

@ -1782,6 +1782,7 @@ export interface ModelMeta {
hidden?: boolean;
capabilities?: object;
profile_image_url?: string;
background_image_url?: string | null;
}
export interface ModelParams {}

View file

@ -1,5 +1,16 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const exportModels = async (token: string, ids: string[]) => {
const query = new URLSearchParams();
ids.forEach((id) => query.append('ids', id));
if (!ids.length) return [];
const response = await fetch(`${WEBUI_API_BASE_URL}/models/export?${query}`, {
headers: { authorization: `Bearer ${token}` }
});
if (!response.ok) throw await response.json();
return response.json();
};
export const getModelItems = async (
token: string = '',
query,

View file

@ -20,6 +20,7 @@
deleteAllModels,
getAllModels,
getModelById,
exportModels,
toggleModelById,
updateModelById,
updateModelAccessGrants,
@ -250,7 +251,14 @@
};
const downloadModels = async (models) => {
models = await Promise.all(models.map(getFullModel));
try {
const exported = [];
for (const model of models) exported.push(await getPortableModel(model));
models = exported;
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
return;
}
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
});
@ -481,6 +489,7 @@
if (res && showToast) {
toast.success($i18n.t('Model updated successfully'));
}
return !!res;
} else {
const res = await createNewModel(localStorage.token, {
meta: {},
@ -496,8 +505,9 @@
if (res && showToast) {
toast.success($i18n.t('Model updated successfully'));
await init();
await init().catch((error) => toast.error(`${error}`));
}
return !!res;
}
};
@ -609,6 +619,11 @@
? ((await getModelById(localStorage.token, model.id).catch(() => null)) ?? model)
: model;
const getPortableModel = async (model: any) =>
isPresetModel(model)
? (await exportModels(localStorage.token, [model.id]))[0]
: getFullModel(model);
const openModelHandler = async (model: any) => {
if (isPresetModel(model)) {
showSettings.set(false);
@ -632,7 +647,12 @@
};
const exportModelHandler = async (model) => {
model = await getFullModel(model);
try {
model = await getPortableModel(model);
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
return;
}
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
});
@ -1249,9 +1269,13 @@
preset={false}
onSubmit={async (model) => {
console.log(model);
await upsertModelHandler(model);
if (!(await upsertModelHandler(model))) {
toast.error($i18n.t('Failed to save model'));
return false;
}
selectedModelId = null;
await init();
await init().catch((error) => toast.error(`${error}`));
return true;
}}
onBack={async () => {
selectedModelId = null;

View file

@ -174,6 +174,17 @@
let askUserTimeoutMs: number | null = null;
let selectedModels = [''];
let selectedModelIdx = 0;
$: selectedModelIdx = Math.max(0, selectedModels.length - 1);
$: backgroundImage = embedded
? null
: ($selectedFolder as { meta?: { background_image_url?: string } } | null)?.meta
?.background_image_url ||
atSelectedModel?.info?.meta?.background_image_url ||
$models.find((model) => model.id === selectedModels[selectedModelIdx])?.info?.meta
?.background_image_url ||
($settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url);
let atSelectedModel: Model | undefined;
let selectedModelIds = [];
$: if (atSelectedModel !== undefined) {
@ -4261,25 +4272,14 @@
>
{#if !loading}
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
{#if !embedded && $selectedFolder && $selectedFolder?.meta?.background_image_url}
{#if backgroundImage}
<div
class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$selectedFolder?.meta?.background_image_url}) "
class="pointer-events-none absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({backgroundImage})"
/>
<div
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/>
{:else if !embedded && ($settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null)}
<div
class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$settings?.backgroundImageUrl ??
$config?.license_metadata?.background_image_url}) "
/>
<div
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/>
class="pointer-events-none absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
></div>
{/if}
<div class="w-full h-full flex">
@ -4587,6 +4587,7 @@
{:else}
<div class="flex items-center h-full">
<Placeholder
bind:selectedModelIdx
{history}
bind:selectedModels
bind:messageInput

View file

@ -85,16 +85,12 @@
export let dragged = false;
let models = [];
let selectedModelIdx = 0;
export let selectedModelIdx = 0;
let selectedModel;
let selectedModelName = '';
let selectedModelDescription = '';
let selectedSuggestionPrompts = [];
$: if (selectedModels.length > 0) {
selectedModelIdx = models.length - 1;
}
$: models = selectedModels.map((id) => $_models.find((m) => m.id === id));
$: selectedModel = atSelectedModel ?? models[selectedModelIdx];
$: selectedModelName = resolveLocalizedModelName(selectedModel, $i18n.language);

View file

@ -9,7 +9,7 @@
import { onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
dayjs.extend(relativeTime);
import {
@ -25,7 +25,8 @@
} from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
createNewModel,
exportModels,
importModels,
deleteModelById,
getModelById,
getModelItems as getWorkspaceModels,
@ -283,7 +284,15 @@
};
const downloadModels = async (models) => {
models = await Promise.all(models.map(getFullModel));
try {
models = await exportModels(
localStorage.token,
models.map((model: { id: string }) => model.id)
);
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
return;
}
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
});
@ -291,7 +300,12 @@
};
const exportModelHandler = async (model) => {
model = await getFullModel(model);
try {
[model] = await exportModels(localStorage.token, [model.id]);
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
return;
}
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
});
@ -476,27 +490,18 @@
return;
}
for (const model of savedModels) {
if (model?.info ?? false) {
if ($_models.find((m) => m.id === model.id)) {
await updateModelById(localStorage.token, model.id, model.info).catch((error) => {
toast.error(`${error}`);
return null;
});
} else {
await createNewModel(localStorage.token, model.info).catch((error) => {
toast.error(`${error}`);
return null;
});
}
} else {
if (model?.id && model?.name) {
await createNewModel(localStorage.token, model).catch((error) => {
toast.error(`${error}`);
return null;
});
}
}
if (!Array.isArray(savedModels)) {
toast.error($i18n.t('Invalid JSON file'));
return;
}
try {
await importModels(
localStorage.token,
savedModels.map((model) => model.info ?? model)
);
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
return;
}
await _models.set(

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte';
import { onMount, onDestroy, getContext, tick } from 'svelte';
import { models, tools, functions, user } from '$lib/stores';
import { WEBUI_BASE_URL, DEFAULT_CAPABILITIES } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL, DEFAULT_CAPABILITIES } from '$lib/constants';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
@ -12,6 +12,7 @@
import { getLanguages } from '$lib/i18n';
import { getBaseModelTags, getModelTags } from '$lib/apis/models';
import { getVoices } from '$lib/apis/audio';
import { uploadFile, deleteFileById } from '$lib/apis/files';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector/Selector.svelte';
@ -50,6 +51,14 @@
export let preset = true;
let loading = false;
let backgroundFile: File | null = null;
let backgroundInput: HTMLInputElement;
let backgroundPreview: string | null = null;
const clearBackgroundPreview = () => {
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview);
backgroundPreview = null;
};
onDestroy(clearBackgroundPreview);
let success = false;
let filesInputElement;
@ -91,6 +100,7 @@
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
profile_image_url: `${WEBUI_BASE_URL}/static/favicon.png`,
background_image_url: null as string | null,
description: '',
i18n: {},
suggestion_prompts: null,
@ -271,6 +281,7 @@
};
const submitHandler = async () => {
if (loading) return;
loading = true;
info.id = id;
@ -423,10 +434,46 @@
}
});
await onSubmit(info);
let uploadedId: string | null = null;
const previousBackground = info.meta.background_image_url;
loading = false;
success = false;
try {
if (backgroundFile) {
const uploaded = await uploadFile(localStorage.token, backgroundFile, null, false, false);
if (!uploaded?.id) throw new Error($i18n.t('Failed to upload background image.'));
uploadedId = uploaded.id;
info.meta.background_image_url = `/api/v1/files/${uploaded.id}/content`;
}
const saved = await onSubmit(info);
if (saved === false) throw new Error($i18n.t('Failed to save model'));
backgroundFile = null;
clearBackgroundPreview();
} catch (error: any) {
info.meta.background_image_url = previousBackground;
if (uploadedId) {
// A failed response can follow a committed save; only delete an unused upload.
try {
const response = await fetch(
`${WEBUI_API_BASE_URL}/models/model?${new URLSearchParams({ id: info.id })}`,
{ headers: { authorization: `Bearer ${localStorage.token}` } }
);
if (
response.status === 404 ||
(response.ok &&
(await response.json())?.meta?.background_image_url !==
`/api/v1/files/${uploadedId}/content`)
) {
await deleteFileById(localStorage.token, uploadedId);
}
} catch {
/* Leave uncertain uploads for file management. */
}
}
toast.error(`${error?.detail ?? error?.message ?? error}`);
} finally {
loading = false;
success = false;
}
};
onMount(async () => {
@ -819,6 +866,92 @@
</div>
{/if}
{#if preset || info.base_model_id}
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-gray-500">{$i18n.t('Background Image')}</span>
<div class="flex gap-3 text-xs">
<button
type="button"
disabled={loading}
on:click={() => backgroundInput.click()}
>
{backgroundPreview || info.meta.background_image_url
? $i18n.t('Replace')
: $i18n.t('Upload')}
</button>
{#if backgroundPreview || info.meta.background_image_url}
<button
type="button"
disabled={loading}
on:click={() => {
clearBackgroundPreview();
backgroundFile = null;
info.meta.background_image_url = null;
}}>{$i18n.t('Reset')}</button
>
{/if}
</div>
</div>
<input
bind:this={backgroundInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
on:change={async () => {
const selected = backgroundInput.files?.[0];
backgroundInput.value = '';
if (!selected || loading) return;
loading = true;
const candidate = URL.createObjectURL(selected);
try {
if (
!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(
selected.type
)
) {
throw new Error(
$i18n.t('Background image must be PNG, JPEG, WebP, or GIF.')
);
}
if (selected.size > 5 * 1024 * 1024)
throw new Error($i18n.t('Background image must be at most 5 MiB.'));
const image = new Image();
image.src = candidate;
await image.decode();
if (image.naturalWidth * image.naturalHeight > 25_000_000) {
throw new Error(
$i18n.t('Background image must be at most 25 megapixels.')
);
}
clearBackgroundPreview();
backgroundPreview = candidate;
backgroundFile = selected;
} catch (error) {
URL.revokeObjectURL(candidate);
toast.error(
error instanceof Error
? error.message
: $i18n.t('Invalid background image.')
);
} finally {
loading = false;
}
}}
/>
{#if backgroundPreview || info.meta.background_image_url}
<img
src={backgroundPreview ?? info.meta.background_image_url}
alt={$i18n.t('Background image preview')}
class="h-28 w-full rounded-lg object-cover"
/>
{/if}
<p class="text-xs text-gray-400">
{$i18n.t('PNG, JPEG, WebP, or GIF. Up to 5 MiB and 25 megapixels.')}
</p>
</div>
{/if}
<div>
<div class="mb-1 flex w-full items-center justify-between">
<div class="self-center text-xs text-gray-400 dark:text-gray-600">