mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-17 23:52:29 +00:00
refac
This commit is contained in:
parent
a096961a31
commit
66addbd6b4
15 changed files with 430 additions and 91 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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}: '
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1782,6 +1782,7 @@ export interface ModelMeta {
|
|||
hidden?: boolean;
|
||||
capabilities?: object;
|
||||
profile_image_url?: string;
|
||||
background_image_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ModelParams {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue