From 66addbd6b47bfb25cf9aa37ec70d24db5b28b6b6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 15 Sep 2026 22:47:03 -0400 Subject: [PATCH] refac --- backend/open_webui/models/auths.py | 4 +- backend/open_webui/models/channels.py | 4 +- backend/open_webui/models/models.py | 27 ++-- backend/open_webui/models/users.py | 10 +- backend/open_webui/routers/models.py | 136 ++++++++++++++++- .../open_webui/utils/access_control/files.py | 4 +- backend/open_webui/utils/oauth.py | 4 +- backend/open_webui/utils/validate.py | 49 ++++-- src/lib/apis/index.ts | 1 + src/lib/apis/models/index.ts | 11 ++ .../components/admin/Settings/Models.svelte | 34 ++++- src/lib/components/chat/Chat.svelte | 33 ++-- src/lib/components/chat/Placeholder.svelte | 6 +- src/lib/components/workspace/Models.svelte | 55 ++++--- .../workspace/Models/ModelEditor.svelte | 143 +++++++++++++++++- 15 files changed, 430 insertions(+), 91 deletions(-) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 629c5eb6c5..b9f8b4b2a0 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -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 diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index c30e2347b9..6742815116 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -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: diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 1a22dc62ff..00a7a2650e 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -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 diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 057f50571b..0e9d2bb6c9 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -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' diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index aa05926a04..f9437b5b87 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -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, diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index efc8b11222..6b8ce00063 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -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(): diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index c88c4c2dab..3bcda023e3 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -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}: ' diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index 333cf56d95..41c7f67b59 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -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//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 diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index e5060fa888..9f9ed0b837 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1782,6 +1782,7 @@ export interface ModelMeta { hidden?: boolean; capabilities?: object; profile_image_url?: string; + background_image_url?: string | null; } export interface ModelParams {} diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 79803e3fb2..eac4b9408a 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -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, diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 4cbfe93011..c9ae680680 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -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; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 2a5bb50da3..ac75fd57bc 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -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}
- {#if !embedded && $selectedFolder && $selectedFolder?.meta?.background_image_url} + {#if backgroundImage}
-
- {:else if !embedded && ($settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null)} -
- -
+ 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" + >
{/if}
@@ -4587,6 +4587,7 @@ {:else}
0) { - selectedModelIdx = models.length - 1; - } - $: models = selectedModels.map((id) => $_models.find((m) => m.id === id)); $: selectedModel = atSelectedModel ?? models[selectedModelIdx]; $: selectedModelName = resolveLocalizedModelName(selectedModel, $i18n.language); diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index 9eaa849819..ecdeaeb95f 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -9,7 +9,7 @@ import { onMount, getContext, tick } from 'svelte'; import { goto } from '$app/navigation'; - const i18n = getContext('i18n'); + const i18n = getContext('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( diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 87e9a89f13..6c800f292a 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -1,9 +1,9 @@