* studio: persist personalization (profile + theme) server-side Profile name/nickname/avatar and appearance (theme) were stored only in the browser's localStorage, so every browser or device that connected to the same Studio started from defaults and forgot the user's personalization. Persist them server-side (single-account, stored as one JSON blob in app_settings) so they follow the account: - utils/personalization_settings.py + GET/PUT /api/settings/personalization, with validation (theme/shape enums, avatar must be an image data URL capped at 512 KB) and a 'saved' flag. - Frontend usePersonalizationSync (mounted in the root layout when signed in) hydrates the profile + theme stores from the server when a blob exists, and otherwise migrates the existing local settings up once so nothing is lost; later changes are written through, debounced. Writers keep using the local stores unchanged. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix/adjust personalization sync for PR #6516 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Studio personalization sync edge cases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com>
199 lines
6.5 KiB
Python
199 lines
6.5 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
from typing import Literal, Optional
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from auth.authentication import get_current_subject
|
|
from loggers import get_logger
|
|
from utils.utils import safe_error_detail, log_and_http_error
|
|
from utils.personalization_settings import (
|
|
MAX_AVATAR_DATA_URL_BYTES,
|
|
PERSONALIZATION_VERSION,
|
|
get_personalization,
|
|
set_personalization,
|
|
)
|
|
from utils.upload_limits import (
|
|
MAX_UPLOAD_LIMIT_MB,
|
|
MIN_UPLOAD_LIMIT_MB,
|
|
default_upload_limit_mb,
|
|
get_upload_limit_mb,
|
|
set_upload_limit_mb,
|
|
upload_limit_bytes,
|
|
upload_limit_label,
|
|
)
|
|
from utils.helper_precache_settings import (
|
|
DEFAULT_HELPER_PRECACHE_ENABLED,
|
|
get_helper_precache_enabled,
|
|
helper_model_disabled_by_env,
|
|
set_helper_precache_enabled,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class UploadLimitPayload(BaseModel):
|
|
max_upload_size_mb: int = Field(..., ge = MIN_UPLOAD_LIMIT_MB, le = MAX_UPLOAD_LIMIT_MB)
|
|
|
|
|
|
class UploadLimitResponse(BaseModel):
|
|
max_upload_size_mb: int
|
|
max_upload_size_bytes: int
|
|
max_upload_size_label: str
|
|
default_upload_size_mb: int
|
|
min_upload_size_mb: int = MIN_UPLOAD_LIMIT_MB
|
|
max_allowed_upload_size_mb: int = MAX_UPLOAD_LIMIT_MB
|
|
|
|
|
|
class HelperPrecachePayload(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class HelperPrecacheResponse(BaseModel):
|
|
enabled: bool
|
|
default_enabled: bool = DEFAULT_HELPER_PRECACHE_ENABLED
|
|
disabled_by_env: bool
|
|
|
|
|
|
def _upload_limit_response(limit_mb: int) -> UploadLimitResponse:
|
|
return UploadLimitResponse(
|
|
max_upload_size_mb = limit_mb,
|
|
max_upload_size_bytes = upload_limit_bytes(limit_mb),
|
|
max_upload_size_label = upload_limit_label(limit_mb),
|
|
default_upload_size_mb = default_upload_limit_mb(),
|
|
)
|
|
|
|
|
|
def _helper_precache_response(enabled: bool | None = None) -> HelperPrecacheResponse:
|
|
return HelperPrecacheResponse(
|
|
enabled = get_helper_precache_enabled() if enabled is None else enabled,
|
|
disabled_by_env = helper_model_disabled_by_env(),
|
|
)
|
|
|
|
|
|
@router.get("/upload-limit", response_model = UploadLimitResponse)
|
|
def get_upload_limit(current_subject: str = Depends(get_current_subject)) -> UploadLimitResponse:
|
|
return _upload_limit_response(get_upload_limit_mb())
|
|
|
|
|
|
@router.put("/upload-limit", response_model = UploadLimitResponse)
|
|
def update_upload_limit(
|
|
payload: UploadLimitPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> UploadLimitResponse:
|
|
try:
|
|
limit_mb = set_upload_limit_mb(payload.max_upload_size_mb)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid upload limit."),
|
|
event = "settings.update_upload_limit_failed",
|
|
log = logger,
|
|
) from exc
|
|
return _upload_limit_response(limit_mb)
|
|
|
|
|
|
@router.get("/helper-precache", response_model = HelperPrecacheResponse)
|
|
def get_helper_precache(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> HelperPrecacheResponse:
|
|
return _helper_precache_response()
|
|
|
|
|
|
@router.put("/helper-precache", response_model = HelperPrecacheResponse)
|
|
def update_helper_precache(
|
|
payload: HelperPrecachePayload, current_subject: str = Depends(get_current_subject)
|
|
) -> HelperPrecacheResponse:
|
|
try:
|
|
enabled = set_helper_precache_enabled(payload.enabled)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid Helper LLM pre-cache setting."),
|
|
event = "settings.update_helper_precache_failed",
|
|
log = logger,
|
|
) from exc
|
|
return _helper_precache_response(enabled)
|
|
|
|
|
|
def _is_bundled_avatar_url(value: str) -> bool:
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme or parsed.netloc:
|
|
return False
|
|
path = unquote(parsed.path).lstrip("/")
|
|
if ".." in path.split("/"):
|
|
return False
|
|
marker = "Sloth emojis/"
|
|
if marker not in path:
|
|
return False
|
|
return path[path.index(marker) :].lower().endswith(".png")
|
|
|
|
|
|
class PersonalizationProfile(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
displayName: str = Field("", max_length = 200)
|
|
nickname: str = Field("", max_length = 200)
|
|
avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES)
|
|
avatarShape: Literal["circle", "rounded"] = "circle"
|
|
|
|
@field_validator("avatarDataUrl")
|
|
@classmethod
|
|
def _validate_avatar(cls, value: Optional[str]) -> Optional[str]:
|
|
if not value:
|
|
return value
|
|
if not value.startswith("data:image/") and not _is_bundled_avatar_url(value):
|
|
raise ValueError("avatarDataUrl must be an image data URL or bundled avatar.")
|
|
return value
|
|
|
|
|
|
class PersonalizationAppearance(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
theme: Literal["light", "dark", "system"] = "system"
|
|
language: Optional[str] = Field(None, max_length = 20)
|
|
|
|
|
|
class PersonalizationPayload(BaseModel):
|
|
model_config = ConfigDict(extra = "ignore")
|
|
|
|
version: int = PERSONALIZATION_VERSION
|
|
profile: PersonalizationProfile = Field(default_factory = PersonalizationProfile)
|
|
appearance: PersonalizationAppearance = Field(default_factory = PersonalizationAppearance)
|
|
|
|
|
|
class PersonalizationResponse(PersonalizationPayload):
|
|
saved: bool = False
|
|
|
|
|
|
@router.get("/personalization", response_model = PersonalizationResponse)
|
|
def get_personalization_settings(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> PersonalizationResponse:
|
|
stored = get_personalization()
|
|
response = PersonalizationResponse.model_validate(stored or {})
|
|
response.saved = bool(stored)
|
|
return response
|
|
|
|
|
|
@router.put("/personalization", response_model = PersonalizationPayload)
|
|
def update_personalization_settings(
|
|
payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject)
|
|
) -> PersonalizationPayload:
|
|
try:
|
|
set_personalization(payload.model_dump())
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
safe_error_detail(exc, fallback = "Invalid personalization settings."),
|
|
event = "settings.update_personalization_failed",
|
|
log = logger,
|
|
) from exc
|
|
return payload
|