Просмотр исходного кода

Merge branch 'dev' into update-ga-strings-june

Aindriú Mac Giolla Eoin 9 месяцев назад
Родитель
Сommit
34ec073b3d
100 измененных файлов с 1431 добавлено и 1232 удалено
  1. 52 5
      backend/open_webui/config.py
  2. 39 5
      backend/open_webui/env.py
  3. 19 13
      backend/open_webui/internal/db.py
  4. 44 4
      backend/open_webui/main.py
  5. 12 2
      backend/open_webui/models/chats.py
  6. 3 1
      backend/open_webui/retrieval/loaders/main.py
  7. 44 36
      backend/open_webui/retrieval/utils.py
  8. 6 3
      backend/open_webui/retrieval/vector/dbs/opensearch.py
  9. 21 1
      backend/open_webui/retrieval/vector/dbs/qdrant.py
  10. 21 1
      backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
  11. 3 1
      backend/open_webui/retrieval/web/brave.py
  12. 14 9
      backend/open_webui/routers/audio.py
  13. 1 0
      backend/open_webui/routers/auths.py
  14. 4 2
      backend/open_webui/routers/chats.py
  15. 14 7
      backend/open_webui/routers/configs.py
  16. 8 7
      backend/open_webui/routers/files.py
  17. 22 6
      backend/open_webui/routers/images.py
  18. 39 37
      backend/open_webui/routers/ollama.py
  19. 55 44
      backend/open_webui/routers/openai.py
  20. 17 0
      backend/open_webui/routers/retrieval.py
  21. 22 4
      backend/open_webui/socket/main.py
  22. 1 1
      backend/open_webui/utils/chat.py
  23. 34 26
      backend/open_webui/utils/middleware.py
  24. 10 2
      backend/open_webui/utils/models.py
  25. 28 1
      backend/open_webui/utils/telemetry/setup.py
  26. 0 3
      backend/open_webui/utils/tools.py
  27. 5 5
      backend/requirements.txt
  28. 1 1
      backend/start_windows.bat
  29. 24 0
      docker-compose.otel.yaml
  30. 42 8
      package-lock.json
  31. 5 2
      package.json
  32. 4 4
      pyproject.toml
  33. 2 2
      scripts/prepare-pyodide.js
  34. 10 32
      src/app.html
  35. 2 0
      src/lib/apis/auths/index.ts
  36. 6 2
      src/lib/apis/chats/index.ts
  37. 4 4
      src/lib/apis/configs/index.ts
  38. 18 8
      src/lib/apis/index.ts
  39. 1 0
      src/lib/apis/users/index.ts
  40. 4 34
      src/lib/components/AddConnectionModal.svelte
  41. 49 47
      src/lib/components/AddServerModal.svelte
  42. 4 10
      src/lib/components/ChangelogModal.svelte
  43. 4 33
      src/lib/components/ImportModal.svelte
  44. 95 38
      src/lib/components/admin/Evaluations/FeedbackModal.svelte
  45. 2 2
      src/lib/components/admin/Evaluations/Feedbacks.svelte
  46. 6 6
      src/lib/components/admin/Evaluations/Leaderboard.svelte
  47. 6 14
      src/lib/components/admin/Evaluations/LeaderboardModal.svelte
  48. 15 5
      src/lib/components/admin/Functions.svelte
  49. 2 27
      src/lib/components/admin/Settings/Audio.svelte
  50. 160 137
      src/lib/components/admin/Settings/Connections.svelte
  51. 2 10
      src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte
  52. 7 33
      src/lib/components/admin/Settings/Documents.svelte
  53. 4 33
      src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte
  54. 14 10
      src/lib/components/admin/Settings/General.svelte
  55. 9 29
      src/lib/components/admin/Settings/Images.svelte
  56. 3 3
      src/lib/components/admin/Settings/Interface.svelte
  57. 17 16
      src/lib/components/admin/Settings/Interface/Banners.svelte
  58. 1 1
      src/lib/components/admin/Settings/Models.svelte
  59. 4 34
      src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte
  60. 1 1
      src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte
  61. 2 10
      src/lib/components/admin/Settings/Models/ManageModelsModal.svelte
  62. 1 1
      src/lib/components/admin/Settings/WebSearch.svelte
  63. 2 12
      src/lib/components/admin/Users/Groups.svelte
  64. 4 33
      src/lib/components/admin/Users/Groups/AddGroupModal.svelte
  65. 4 33
      src/lib/components/admin/Users/Groups/EditGroupModal.svelte
  66. 2 12
      src/lib/components/admin/Users/Groups/Users.svelte
  67. 1 1
      src/lib/components/admin/Users/UserList.svelte
  68. 4 33
      src/lib/components/admin/Users/UserList/AddUserModal.svelte
  69. 2 10
      src/lib/components/admin/Users/UserList/EditUserModal.svelte
  70. 46 34
      src/lib/components/channel/MessageInput.svelte
  71. 11 10
      src/lib/components/chat/Chat.svelte
  72. 7 3
      src/lib/components/chat/ChatPlaceholder.svelte
  73. 4 2
      src/lib/components/chat/Controls/Controls.svelte
  74. 61 38
      src/lib/components/chat/MessageInput.svelte
  75. 1 0
      src/lib/components/chat/MessageInput/CallOverlay.svelte
  76. 34 8
      src/lib/components/chat/MessageInput/Commands/Knowledge.svelte
  77. 28 1
      src/lib/components/chat/MessageInput/Commands/Models.svelte
  78. 30 1
      src/lib/components/chat/MessageInput/Commands/Prompts.svelte
  79. 2 10
      src/lib/components/chat/MessageInput/VoiceRecording.svelte
  80. 1 0
      src/lib/components/chat/Messages.svelte
  81. 0 1
      src/lib/components/chat/Messages/Citations.svelte
  82. 3 10
      src/lib/components/chat/Messages/CitationsModal.svelte
  83. 2 10
      src/lib/components/chat/Messages/CodeExecutionModal.svelte
  84. 11 6
      src/lib/components/chat/Messages/ContentRenderer.svelte
  85. 2 1
      src/lib/components/chat/Messages/Markdown.svelte
  86. 3 0
      src/lib/components/chat/Messages/Message.svelte
  87. 2 0
      src/lib/components/chat/Messages/MultiResponseMessages.svelte
  88. 2 10
      src/lib/components/chat/Messages/RateComment.svelte
  89. 4 2
      src/lib/components/chat/Messages/ResponseMessage.svelte
  90. 2 2
      src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte
  91. 24 39
      src/lib/components/chat/Messages/Skeleton.svelte
  92. 1 1
      src/lib/components/chat/Messages/UserMessage.svelte
  93. 1 1
      src/lib/components/chat/ModelSelector.svelte
  94. 11 28
      src/lib/components/chat/ModelSelector/Selector.svelte
  95. 4 4
      src/lib/components/chat/Navbar.svelte
  96. 11 5
      src/lib/components/chat/Placeholder.svelte
  97. 13 9
      src/lib/components/chat/Settings/About.svelte
  98. 14 5
      src/lib/components/chat/Settings/Chats.svelte
  99. 4 1
      src/lib/components/chat/Settings/General.svelte
  100. 4 33
      src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte

+ 52 - 5
backend/open_webui/config.py

@@ -19,6 +19,7 @@ from open_webui.env import (
     DATABASE_URL,
     ENV,
     REDIS_URL,
+    REDIS_KEY_PREFIX,
     REDIS_SENTINEL_HOSTS,
     REDIS_SENTINEL_PORT,
     FRONTEND_BUILD_DIR,
@@ -211,11 +212,16 @@ class PersistentConfig(Generic[T]):
 class AppConfig:
     _state: dict[str, PersistentConfig]
     _redis: Optional[redis.Redis] = None
+    _redis_key_prefix: str
 
     def __init__(
-        self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
+        self,
+        redis_url: Optional[str] = None,
+        redis_sentinels: Optional[list] = [],
+        redis_key_prefix: str = "open-webui",
     ):
         super().__setattr__("_state", {})
+        super().__setattr__("_redis_key_prefix", redis_key_prefix)
         if redis_url:
             super().__setattr__(
                 "_redis",
@@ -230,7 +236,7 @@ class AppConfig:
             self._state[key].save()
 
             if self._redis:
-                redis_key = f"open-webui:config:{key}"
+                redis_key = f"{self._redis_key_prefix}:config:{key}"
                 self._redis.set(redis_key, json.dumps(self._state[key].value))
 
     def __getattr__(self, key):
@@ -239,7 +245,7 @@ class AppConfig:
 
         # If Redis is available, check for an updated value
         if self._redis:
-            redis_key = f"open-webui:config:{key}"
+            redis_key = f"{self._redis_key_prefix}:config:{key}"
             redis_value = self._redis.get(redis_key)
 
             if redis_value is not None:
@@ -431,6 +437,12 @@ OAUTH_SCOPES = PersistentConfig(
     os.environ.get("OAUTH_SCOPES", "openid email profile"),
 )
 
+OAUTH_TIMEOUT = PersistentConfig(
+    "OAUTH_TIMEOUT",
+    "oauth.oidc.oauth_timeout",
+    os.environ.get("OAUTH_TIMEOUT", ""),
+)
+
 OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
     "OAUTH_CODE_CHALLENGE_METHOD",
     "oauth.oidc.code_challenge_method",
@@ -540,7 +552,14 @@ def load_oauth_providers():
                 client_id=GOOGLE_CLIENT_ID.value,
                 client_secret=GOOGLE_CLIENT_SECRET.value,
                 server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
-                client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
+                client_kwargs={
+                    "scope": GOOGLE_OAUTH_SCOPE.value,
+                    **(
+                        {"timeout": int(OAUTH_TIMEOUT.value)}
+                        if OAUTH_TIMEOUT.value
+                        else {}
+                    ),
+                },
                 redirect_uri=GOOGLE_REDIRECT_URI.value,
             )
 
@@ -563,6 +582,11 @@ def load_oauth_providers():
                 server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
                 client_kwargs={
                     "scope": MICROSOFT_OAUTH_SCOPE.value,
+                    **(
+                        {"timeout": int(OAUTH_TIMEOUT.value)}
+                        if OAUTH_TIMEOUT.value
+                        else {}
+                    ),
                 },
                 redirect_uri=MICROSOFT_REDIRECT_URI.value,
             )
@@ -584,7 +608,14 @@ def load_oauth_providers():
                 authorize_url="https://github.com/login/oauth/authorize",
                 api_base_url="https://api.github.com",
                 userinfo_endpoint="https://api.github.com/user",
-                client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
+                client_kwargs={
+                    "scope": GITHUB_CLIENT_SCOPE.value,
+                    **(
+                        {"timeout": int(OAUTH_TIMEOUT.value)}
+                        if OAUTH_TIMEOUT.value
+                        else {}
+                    ),
+                },
                 redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
             )
 
@@ -603,6 +634,9 @@ def load_oauth_providers():
         def oidc_oauth_register(client):
             client_kwargs = {
                 "scope": OAUTH_SCOPES.value,
+                **(
+                    {"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
+                ),
             }
 
             if (
@@ -895,6 +929,18 @@ except Exception:
     pass
 OPENAI_API_BASE_URL = "https://api.openai.com/v1"
 
+
+####################################
+# MODELS
+####################################
+
+ENABLE_BASE_MODELS_CACHE = PersistentConfig(
+    "ENABLE_BASE_MODELS_CACHE",
+    "models.base_models_cache",
+    os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true",
+)
+
+
 ####################################
 # TOOL_SERVERS
 ####################################
@@ -1799,6 +1845,7 @@ QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
 ENABLE_QDRANT_MULTITENANCY_MODE = (
     os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
 )
+QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
 
 # OpenSearch
 OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")

+ 39 - 5
backend/open_webui/env.py

@@ -199,6 +199,7 @@ CHANGELOG = changelog_json
 
 SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
 
+
 ####################################
 # ENABLE_FORWARD_USER_INFO_HEADERS
 ####################################
@@ -272,15 +273,13 @@ if "postgres://" in DATABASE_URL:
 
 DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
 
-DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
+DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
 
-if DATABASE_POOL_SIZE == "":
-    DATABASE_POOL_SIZE = 0
-else:
+if DATABASE_POOL_SIZE != None:
     try:
         DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
     except Exception:
-        DATABASE_POOL_SIZE = 0
+        DATABASE_POOL_SIZE = None
 
 DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
 
@@ -325,6 +324,7 @@ ENABLE_REALTIME_CHAT_SAVE = (
 ####################################
 
 REDIS_URL = os.environ.get("REDIS_URL", "")
+REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
 REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
 REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
 
@@ -396,10 +396,33 @@ WEBUI_AUTH_COOKIE_SECURE = (
 if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
     raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
 
+ENABLE_COMPRESSION_MIDDLEWARE = (
+    os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
+)
+
+####################################
+# MODELS
+####################################
+
+MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
+if MODELS_CACHE_TTL == "":
+    MODELS_CACHE_TTL = None
+else:
+    try:
+        MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
+    except Exception:
+        MODELS_CACHE_TTL = 1
+
+
+####################################
+# WEBSOCKET SUPPORT
+####################################
+
 ENABLE_WEBSOCKET_SUPPORT = (
     os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
 )
 
+
 WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
 
 WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
@@ -543,6 +566,9 @@ ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() ==
 OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
     "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
 )
+OTEL_EXPORTER_OTLP_INSECURE = (
+    os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
+)
 OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
 OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
     "OTEL_RESOURCE_ATTRIBUTES", ""
@@ -550,6 +576,14 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
 OTEL_TRACES_SAMPLER = os.environ.get(
     "OTEL_TRACES_SAMPLER", "parentbased_always_on"
 ).lower()
+OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
+OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
+
+
+OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
+    "OTEL_OTLP_SPAN_EXPORTER", "grpc"
+).lower()  # grpc or http
+
 
 ####################################
 # TOOLS/FUNCTIONS PIP OPTIONS

+ 19 - 13
backend/open_webui/internal/db.py

@@ -62,6 +62,9 @@ def handle_peewee_migration(DATABASE_URL):
 
     except Exception as e:
         log.error(f"Failed to initialize the database connection: {e}")
+        log.warning(
+            "Hint: If your database password contains special characters, you may need to URL-encode it."
+        )
         raise
     finally:
         # Properly closing the database connection
@@ -81,20 +84,23 @@ if "sqlite" in SQLALCHEMY_DATABASE_URL:
         SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
     )
 else:
-    if DATABASE_POOL_SIZE > 0:
-        engine = create_engine(
-            SQLALCHEMY_DATABASE_URL,
-            pool_size=DATABASE_POOL_SIZE,
-            max_overflow=DATABASE_POOL_MAX_OVERFLOW,
-            pool_timeout=DATABASE_POOL_TIMEOUT,
-            pool_recycle=DATABASE_POOL_RECYCLE,
-            pool_pre_ping=True,
-            poolclass=QueuePool,
-        )
+    if isinstance(DATABASE_POOL_SIZE, int):
+        if DATABASE_POOL_SIZE > 0:
+            engine = create_engine(
+                SQLALCHEMY_DATABASE_URL,
+                pool_size=DATABASE_POOL_SIZE,
+                max_overflow=DATABASE_POOL_MAX_OVERFLOW,
+                pool_timeout=DATABASE_POOL_TIMEOUT,
+                pool_recycle=DATABASE_POOL_RECYCLE,
+                pool_pre_ping=True,
+                poolclass=QueuePool,
+            )
+        else:
+            engine = create_engine(
+                SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
+            )
     else:
-        engine = create_engine(
-            SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
-        )
+        engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
 
 
 SessionLocal = sessionmaker(

+ 44 - 4
backend/open_webui/main.py

@@ -36,7 +36,6 @@ from fastapi import (
     applications,
     BackgroundTasks,
 )
-
 from fastapi.openapi.docs import get_swagger_ui_html
 
 from fastapi.middleware.cors import CORSMiddleware
@@ -49,6 +48,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.middleware.sessions import SessionMiddleware
 from starlette.responses import Response, StreamingResponse
+from starlette.datastructures import Headers
 
 
 from open_webui.utils import logger
@@ -116,6 +116,8 @@ from open_webui.config import (
     OPENAI_API_CONFIGS,
     # Direct Connections
     ENABLE_DIRECT_CONNECTIONS,
+    # Model list
+    ENABLE_BASE_MODELS_CACHE,
     # Thread pool size for FastAPI/AnyIO
     THREAD_POOL_SIZE,
     # Tool Server Configs
@@ -396,6 +398,7 @@ from open_webui.env import (
     AUDIT_LOG_LEVEL,
     CHANGELOG,
     REDIS_URL,
+    REDIS_KEY_PREFIX,
     REDIS_SENTINEL_HOSTS,
     REDIS_SENTINEL_PORT,
     GLOBAL_LOG_LEVEL,
@@ -411,6 +414,7 @@ from open_webui.env import (
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     WEBUI_AUTH_TRUSTED_NAME_HEADER,
     WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
+    ENABLE_COMPRESSION_MIDDLEWARE,
     ENABLE_WEBSOCKET_SUPPORT,
     BYPASS_MODEL_ACCESS_CONTROL,
     RESET_CONFIG_ON_START,
@@ -533,6 +537,27 @@ async def lifespan(app: FastAPI):
 
     asyncio.create_task(periodic_usage_pool_cleanup())
 
+    if app.state.config.ENABLE_BASE_MODELS_CACHE:
+        await get_all_models(
+            Request(
+                # Creating a mock request object to pass to get_all_models
+                {
+                    "type": "http",
+                    "asgi.version": "3.0",
+                    "asgi.spec_version": "2.0",
+                    "method": "GET",
+                    "path": "/internal",
+                    "query_string": b"",
+                    "headers": Headers({}).raw,
+                    "client": ("127.0.0.1", 12345),
+                    "server": ("127.0.0.1", 80),
+                    "scheme": "http",
+                    "app": app,
+                }
+            ),
+            None,
+        )
+
     yield
 
     if hasattr(app.state, "redis_task_command_listener"):
@@ -553,6 +578,7 @@ app.state.instance_id = None
 app.state.config = AppConfig(
     redis_url=REDIS_URL,
     redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
+    redis_key_prefix=REDIS_KEY_PREFIX,
 )
 app.state.redis = None
 
@@ -615,6 +641,15 @@ app.state.TOOL_SERVERS = []
 
 app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
 
+########################################
+#
+# MODELS
+#
+########################################
+
+app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE
+app.state.BASE_MODELS = []
+
 ########################################
 #
 # WEBUI
@@ -1072,7 +1107,9 @@ class RedirectMiddleware(BaseHTTPMiddleware):
 
 
 # Add the middleware to the app
-app.add_middleware(CompressMiddleware)
+if ENABLE_COMPRESSION_MIDDLEWARE:
+    app.add_middleware(CompressMiddleware)
+
 app.add_middleware(RedirectMiddleware)
 app.add_middleware(SecurityHeadersMiddleware)
 
@@ -1188,7 +1225,9 @@ if audit_level != AuditLevel.NONE:
 
 
 @app.get("/api/models")
-async def get_models(request: Request, user=Depends(get_verified_user)):
+async def get_models(
+    request: Request, refresh: bool = False, user=Depends(get_verified_user)
+):
     def get_filtered_models(models, user):
         filtered_models = []
         for model in models:
@@ -1212,7 +1251,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
 
         return filtered_models
 
-    all_models = await get_all_models(request, user=user)
+    all_models = await get_all_models(request, refresh=refresh, user=user)
 
     models = []
     for model in all_models:
@@ -1507,6 +1546,7 @@ async def get_app_config(request: Request):
         "name": app.state.WEBUI_NAME,
         "version": VERSION,
         "default_locale": str(DEFAULT_LOCALE),
+        "offline_mode": OFFLINE_MODE,
         "oauth": {
             "providers": {
                 name: config.get("name", name)

+ 12 - 2
backend/open_webui/models/chats.py

@@ -72,6 +72,8 @@ class ChatImportForm(ChatForm):
     meta: Optional[dict] = {}
     pinned: Optional[bool] = False
     folder_id: Optional[str] = None
+    created_at: Optional[int] = None
+    updated_at: Optional[int] = None
 
 
 class ChatTitleMessagesForm(BaseModel):
@@ -147,8 +149,16 @@ class ChatTable:
                     "meta": form_data.meta,
                     "pinned": form_data.pinned,
                     "folder_id": form_data.folder_id,
-                    "created_at": int(time.time()),
-                    "updated_at": int(time.time()),
+                    "created_at": (
+                        form_data.created_at
+                        if form_data.created_at
+                        else int(time.time())
+                    ),
+                    "updated_at": (
+                        form_data.updated_at
+                        if form_data.updated_at
+                        else int(time.time())
+                    ),
                 }
             )
 

+ 3 - 1
backend/open_webui/retrieval/loaders/main.py

@@ -14,7 +14,7 @@ from langchain_community.document_loaders import (
     TextLoader,
     UnstructuredEPubLoader,
     UnstructuredExcelLoader,
-    UnstructuredMarkdownLoader,
+    UnstructuredODTLoader,
     UnstructuredPowerPointLoader,
     UnstructuredRSTLoader,
     UnstructuredXMLLoader,
@@ -389,6 +389,8 @@ class Loader:
                 loader = UnstructuredPowerPointLoader(file_path)
             elif file_ext == "msg":
                 loader = OutlookMessageLoader(file_path)
+            elif file_ext == "odt":
+                loader = UnstructuredODTLoader(file_path)
             elif self._is_text_file(file_ext, file_content_type):
                 loader = TextLoader(file_path, autodetect_encoding=True)
             else:

+ 44 - 36
backend/open_webui/retrieval/utils.py

@@ -7,6 +7,7 @@ import hashlib
 from concurrent.futures import ThreadPoolExecutor
 import time
 
+from urllib.parse import quote
 from huggingface_hub import snapshot_download
 from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
 from langchain_community.retrievers import BM25Retriever
@@ -459,20 +460,19 @@ def get_sources_from_files(
     )
 
     extracted_collections = []
-    relevant_contexts = []
+    query_results = []
 
     for file in files:
-
-        context = None
+        query_result = None
         if file.get("docs"):
             # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
-            context = {
+            query_result = {
                 "documents": [[doc.get("content") for doc in file.get("docs")]],
                 "metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
             }
         elif file.get("context") == "full":
             # Manual Full Mode Toggle
-            context = {
+            query_result = {
                 "documents": [[file.get("file").get("data", {}).get("content")]],
                 "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
             }
@@ -499,7 +499,7 @@ def get_sources_from_files(
                             }
                         )
 
-                context = {
+                query_result = {
                     "documents": [documents],
                     "metadatas": [metadatas],
                 }
@@ -507,7 +507,7 @@ def get_sources_from_files(
             elif file.get("id"):
                 file_object = Files.get_file_by_id(file.get("id"))
                 if file_object:
-                    context = {
+                    query_result = {
                         "documents": [[file_object.data.get("content", "")]],
                         "metadatas": [
                             [
@@ -520,7 +520,7 @@ def get_sources_from_files(
                         ],
                     }
             elif file.get("file").get("data"):
-                context = {
+                query_result = {
                     "documents": [[file.get("file").get("data", {}).get("content")]],
                     "metadatas": [
                         [file.get("file").get("data", {}).get("metadata", {})]
@@ -548,19 +548,27 @@ def get_sources_from_files(
 
             if full_context:
                 try:
-                    context = get_all_items_from_collections(collection_names)
+                    query_result = get_all_items_from_collections(collection_names)
                 except Exception as e:
                     log.exception(e)
 
             else:
                 try:
-                    context = None
+                    query_result = None
                     if file.get("type") == "text":
-                        context = file["content"]
+                        # Not sure when this is used, but it seems to be a fallback
+                        query_result = {
+                            "documents": [
+                                [file.get("file").get("data", {}).get("content")]
+                            ],
+                            "metadatas": [
+                                [file.get("file").get("data", {}).get("meta", {})]
+                            ],
+                        }
                     else:
                         if hybrid_search:
                             try:
-                                context = query_collection_with_hybrid_search(
+                                query_result = query_collection_with_hybrid_search(
                                     collection_names=collection_names,
                                     queries=queries,
                                     embedding_function=embedding_function,
@@ -576,8 +584,8 @@ def get_sources_from_files(
                                     " non hybrid search as fallback."
                                 )
 
-                        if (not hybrid_search) or (context is None):
-                            context = query_collection(
+                        if (not hybrid_search) or (query_result is None):
+                            query_result = query_collection(
                                 collection_names=collection_names,
                                 queries=queries,
                                 embedding_function=embedding_function,
@@ -588,24 +596,24 @@ def get_sources_from_files(
 
             extracted_collections.extend(collection_names)
 
-        if context:
+        if query_result:
             if "data" in file:
                 del file["data"]
 
-            relevant_contexts.append({**context, "file": file})
+            query_results.append({**query_result, "file": file})
 
     sources = []
-    for context in relevant_contexts:
+    for query_result in query_results:
         try:
-            if "documents" in context:
-                if "metadatas" in context:
+            if "documents" in query_result:
+                if "metadatas" in query_result:
                     source = {
-                        "source": context["file"],
-                        "document": context["documents"][0],
-                        "metadata": context["metadatas"][0],
+                        "source": query_result["file"],
+                        "document": query_result["documents"][0],
+                        "metadata": query_result["metadatas"][0],
                     }
-                    if "distances" in context and context["distances"]:
-                        source["distances"] = context["distances"][0]
+                    if "distances" in query_result and query_result["distances"]:
+                        source["distances"] = query_result["distances"][0]
 
                     sources.append(source)
         except Exception as e:
@@ -678,10 +686,10 @@ def generate_openai_batch_embeddings(
                 "Authorization": f"Bearer {key}",
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -727,10 +735,10 @@ def generate_azure_openai_batch_embeddings(
                     "api-key": key,
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS and user
                         else {}
@@ -777,10 +785,10 @@ def generate_ollama_batch_embeddings(
                 "Authorization": f"Bearer {key}",
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS
                     else {}

+ 6 - 3
backend/open_webui/retrieval/vector/dbs/opensearch.py

@@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase):
 
         for field, value in filter.items():
             query_body["query"]["bool"]["filter"].append(
-                {"match": {"metadata." + str(field): value}}
+                {"term": {"metadata." + str(field) + ".keyword": value}}
             )
 
-        size = limit if limit else 10
+        size = limit if limit else 10000
 
         try:
             result = self.client.search(
@@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase):
                 for item in batch
             ]
             bulk(self.client, actions)
+        self.client.indices.refresh(self._get_index_name(collection_name))
 
     def upsert(self, collection_name: str, items: list[VectorItem]):
         self._create_index_if_not_exists(
@@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase):
                 for item in batch
             ]
             bulk(self.client, actions)
+        self.client.indices.refresh(self._get_index_name(collection_name))
 
     def delete(
         self,
@@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase):
             }
             for field, value in filter.items():
                 query_body["query"]["bool"]["filter"].append(
-                    {"match": {"metadata." + str(field): value}}
+                    {"term": {"metadata." + str(field) + ".keyword": value}}
                 )
             self.client.delete_by_query(
                 index=self._get_index_name(collection_name), body=query_body
             )
+        self.client.indices.refresh(self._get_index_name(collection_name))
 
     def reset(self):
         indices = self.client.indices.get(index=f"{self.index_prefix}_*")

+ 21 - 1
backend/open_webui/retrieval/vector/dbs/qdrant.py

@@ -18,6 +18,7 @@ from open_webui.config import (
     QDRANT_ON_DISK,
     QDRANT_GRPC_PORT,
     QDRANT_PREFER_GRPC,
+    QDRANT_COLLECTION_PREFIX,
 )
 from open_webui.env import SRC_LOG_LEVELS
 
@@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
 
 class QdrantClient(VectorDBBase):
     def __init__(self):
-        self.collection_prefix = "open-webui"
+        self.collection_prefix = QDRANT_COLLECTION_PREFIX
         self.QDRANT_URI = QDRANT_URI
         self.QDRANT_API_KEY = QDRANT_API_KEY
         self.QDRANT_ON_DISK = QDRANT_ON_DISK
@@ -86,6 +87,25 @@ class QdrantClient(VectorDBBase):
             ),
         )
 
+        # Create payload indexes for efficient filtering
+        self.client.create_payload_index(
+            collection_name=collection_name_with_prefix,
+            field_name="metadata.hash",
+            field_schema=models.KeywordIndexParams(
+                type=models.KeywordIndexType.KEYWORD,
+                is_tenant=False,
+                on_disk=self.QDRANT_ON_DISK,
+            ),
+        )
+        self.client.create_payload_index(
+            collection_name=collection_name_with_prefix,
+            field_name="metadata.file_id",
+            field_schema=models.KeywordIndexParams(
+                type=models.KeywordIndexType.KEYWORD,
+                is_tenant=False,
+                on_disk=self.QDRANT_ON_DISK,
+            ),
+        )
         log.info(f"collection {collection_name_with_prefix} successfully created!")
 
     def _create_collection_if_not_exists(self, collection_name, dimension):

+ 21 - 1
backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py

@@ -9,6 +9,7 @@ from open_webui.config import (
     QDRANT_ON_DISK,
     QDRANT_PREFER_GRPC,
     QDRANT_URI,
+    QDRANT_COLLECTION_PREFIX,
 )
 from open_webui.env import SRC_LOG_LEVELS
 from open_webui.retrieval.vector.main import (
@@ -30,7 +31,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
 
 class QdrantClient(VectorDBBase):
     def __init__(self):
-        self.collection_prefix = "open-webui"
+        self.collection_prefix = QDRANT_COLLECTION_PREFIX
         self.QDRANT_URI = QDRANT_URI
         self.QDRANT_API_KEY = QDRANT_API_KEY
         self.QDRANT_ON_DISK = QDRANT_ON_DISK
@@ -228,6 +229,25 @@ class QdrantClient(VectorDBBase):
                 ),
                 wait=True,
             )
+            # Create payload indexes for efficient filtering on metadata.hash and metadata.file_id
+            self.client.create_payload_index(
+                collection_name=mt_collection_name,
+                field_name="metadata.hash",
+                field_schema=models.KeywordIndexParams(
+                    type=models.KeywordIndexType.KEYWORD,
+                    is_tenant=False,
+                    on_disk=self.QDRANT_ON_DISK,
+                ),
+            )
+            self.client.create_payload_index(
+                collection_name=mt_collection_name,
+                field_name="metadata.file_id",
+                field_schema=models.KeywordIndexParams(
+                    type=models.KeywordIndexType.KEYWORD,
+                    is_tenant=False,
+                    on_disk=self.QDRANT_ON_DISK,
+                ),
+            )
 
             log.info(
                 f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"

+ 3 - 1
backend/open_webui/retrieval/web/brave.py

@@ -36,7 +36,9 @@ def search_brave(
 
     return [
         SearchResult(
-            link=result["url"], title=result.get("title"), snippet=result.get("snippet")
+            link=result["url"],
+            title=result.get("title"),
+            snippet=result.get("description"),
         )
         for result in results[:count]
     ]

+ 14 - 9
backend/open_webui/routers/audio.py

@@ -15,6 +15,7 @@ import aiohttp
 import aiofiles
 import requests
 import mimetypes
+from urllib.parse import quote
 
 from fastapi import (
     Depends,
@@ -343,10 +344,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                         "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
                         **(
                             {
-                                "X-OpenWebUI-User-Name": user.name,
-                                "X-OpenWebUI-User-Id": user.id,
-                                "X-OpenWebUI-User-Email": user.email,
-                                "X-OpenWebUI-User-Role": user.role,
+                                "X-OpenWebUI-User-Name": quote(user.name),
+                                "X-OpenWebUI-User-Id": quote(user.id),
+                                "X-OpenWebUI-User-Email": quote(user.email),
+                                "X-OpenWebUI-User-Role": quote(user.role),
                             }
                             if ENABLE_FORWARD_USER_INFO_HEADERS
                             else {}
@@ -919,14 +920,18 @@ def transcription(
 ):
     log.info(f"file.content_type: {file.content_type}")
 
-    supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
-        "audio/*",
-        "video/webm",
-    ]
+    stt_supported_content_types = getattr(
+        request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
+    )
 
     if not any(
         fnmatch(file.content_type, content_type)
-        for content_type in supported_content_types
+        for content_type in (
+            stt_supported_content_types
+            if stt_supported_content_types
+            and any(t.strip() for t in stt_supported_content_types)
+            else ["audio/*", "video/webm"]
+        )
     ):
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,

+ 1 - 0
backend/open_webui/routers/auths.py

@@ -669,6 +669,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
 @router.get("/signout")
 async def signout(request: Request, response: Response):
     response.delete_cookie("token")
+    response.delete_cookie("oui-session")
 
     if ENABLE_OAUTH_SIGNUP.value:
         oauth_id_token = request.cookies.get("oauth_id_token")

+ 4 - 2
backend/open_webui/routers/chats.py

@@ -684,8 +684,10 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
 
 @router.post("/{id}/share", response_model=Optional[ChatResponse])
 async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
-    if not has_permission(
-        user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
+    if (user.role != "admin") and (
+        not has_permission(
+            user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
+        )
     ):
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,

+ 14 - 7
backend/open_webui/routers/configs.py

@@ -39,32 +39,39 @@ async def export_config(user=Depends(get_admin_user)):
 
 
 ############################
-# Direct Connections Config
+# Connections Config
 ############################
 
 
-class DirectConnectionsConfigForm(BaseModel):
+class ConnectionsConfigForm(BaseModel):
     ENABLE_DIRECT_CONNECTIONS: bool
+    ENABLE_BASE_MODELS_CACHE: bool
 
 
-@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
-async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
+@router.get("/connections", response_model=ConnectionsConfigForm)
+async def get_connections_config(request: Request, user=Depends(get_admin_user)):
     return {
         "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
+        "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
     }
 
 
-@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
-async def set_direct_connections_config(
+@router.post("/connections", response_model=ConnectionsConfigForm)
+async def set_connections_config(
     request: Request,
-    form_data: DirectConnectionsConfigForm,
+    form_data: ConnectionsConfigForm,
     user=Depends(get_admin_user),
 ):
     request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
         form_data.ENABLE_DIRECT_CONNECTIONS
     )
+    request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
+        form_data.ENABLE_BASE_MODELS_CACHE
+    )
+
     return {
         "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
+        "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
     }
 
 

+ 8 - 7
backend/open_webui/routers/files.py

@@ -155,17 +155,18 @@ def upload_file(
         if process:
             try:
                 if file.content_type:
-                    stt_supported_content_types = (
-                        request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
-                        or [
-                            "audio/*",
-                            "video/webm",
-                        ]
+                    stt_supported_content_types = getattr(
+                        request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
                     )
 
                     if any(
                         fnmatch(file.content_type, content_type)
-                        for content_type in stt_supported_content_types
+                        for content_type in (
+                            stt_supported_content_types
+                            if stt_supported_content_types
+                            and any(t.strip() for t in stt_supported_content_types)
+                            else ["audio/*", "video/webm"]
+                        )
                     ):
                         file_path = Storage.get_file(file_path)
                         result = transcribe(request, file_path, file_metadata)

+ 22 - 6
backend/open_webui/routers/images.py

@@ -8,6 +8,7 @@ import re
 from pathlib import Path
 from typing import Optional
 
+from urllib.parse import quote
 import requests
 from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
 from open_webui.config import CACHE_DIR
@@ -302,8 +303,16 @@ async def update_image_config(
 ):
     set_image_model(request, form_data.MODEL)
 
+    if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1":
+        raise HTTPException(
+            status_code=400,
+            detail=ERROR_MESSAGES.INCORRECT_FORMAT(
+                "  (auto is only allowed with gpt-image-1)."
+            ),
+        )
+
     pattern = r"^\d+x\d+$"
-    if re.match(pattern, form_data.IMAGE_SIZE):
+    if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE):
         request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
     else:
         raise HTTPException(
@@ -471,7 +480,14 @@ async def image_generations(
     form_data: GenerateImageForm,
     user=Depends(get_verified_user),
 ):
-    width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
+    # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
+    # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
+    # image model other than gpt-image-1, which is warned about on settings save
+    width, height = (
+        tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
+        if "x" in request.app.state.config.IMAGE_SIZE
+        else (512, 512)
+    )
 
     r = None
     try:
@@ -483,10 +499,10 @@ async def image_generations(
             headers["Content-Type"] = "application/json"
 
             if ENABLE_FORWARD_USER_INFO_HEADERS:
-                headers["X-OpenWebUI-User-Name"] = user.name
-                headers["X-OpenWebUI-User-Id"] = user.id
-                headers["X-OpenWebUI-User-Email"] = user.email
-                headers["X-OpenWebUI-User-Role"] = user.role
+                headers["X-OpenWebUI-User-Name"] = quote(user.name)
+                headers["X-OpenWebUI-User-Id"] = quote(user.id)
+                headers["X-OpenWebUI-User-Email"] = quote(user.email)
+                headers["X-OpenWebUI-User-Role"] = quote(user.role)
 
             data = {
                 "model": (

+ 39 - 37
backend/open_webui/routers/ollama.py

@@ -16,6 +16,7 @@ from urllib.parse import urlparse
 import aiohttp
 from aiocache import cached
 import requests
+from urllib.parse import quote
 
 from open_webui.models.chats import Chats
 from open_webui.models.users import UserModel
@@ -58,6 +59,7 @@ from open_webui.config import (
 from open_webui.env import (
     ENV,
     SRC_LOG_LEVELS,
+    MODELS_CACHE_TTL,
     AIOHTTP_CLIENT_SESSION_SSL,
     AIOHTTP_CLIENT_TIMEOUT,
     AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@@ -87,10 +89,10 @@ async def send_get_request(url, key=None, user: UserModel = None):
                     **({"Authorization": f"Bearer {key}"} if key else {}),
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS and user
                         else {}
@@ -138,10 +140,10 @@ async def send_post_request(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -242,10 +244,10 @@ async def verify_connection(
                     **({"Authorization": f"Bearer {key}"} if key else {}),
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS and user
                         else {}
@@ -329,7 +331,7 @@ def merge_ollama_models_lists(model_lists):
     return list(merged_models.values())
 
 
-@cached(ttl=1)
+@cached(ttl=MODELS_CACHE_TTL)
 async def get_all_models(request: Request, user: UserModel = None):
     log.info("get_all_models()")
     if request.app.state.config.ENABLE_OLLAMA_API:
@@ -462,10 +464,10 @@ async def get_ollama_tags(
                     **({"Authorization": f"Bearer {key}"} if key else {}),
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS and user
                         else {}
@@ -824,10 +826,10 @@ async def copy_model(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -890,10 +892,10 @@ async def delete_model(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -949,10 +951,10 @@ async def show_model_info(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -1036,10 +1038,10 @@ async def embed(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -1123,10 +1125,10 @@ async def embeddings(
                 **({"Authorization": f"Bearer {key}"} if key else {}),
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}

+ 55 - 44
backend/open_webui/routers/openai.py

@@ -8,7 +8,7 @@ from typing import Literal, Optional, overload
 import aiohttp
 from aiocache import cached
 import requests
-
+from urllib.parse import quote
 
 from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
 from fastapi.middleware.cors import CORSMiddleware
@@ -21,6 +21,7 @@ from open_webui.config import (
     CACHE_DIR,
 )
 from open_webui.env import (
+    MODELS_CACHE_TTL,
     AIOHTTP_CLIENT_SESSION_SSL,
     AIOHTTP_CLIENT_TIMEOUT,
     AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@@ -66,10 +67,10 @@ async def send_get_request(url, key=None, user: UserModel = None):
                     **({"Authorization": f"Bearer {key}"} if key else {}),
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS and user
                         else {}
@@ -225,10 +226,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                     ),
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS
                         else {}
@@ -386,7 +387,7 @@ async def get_filtered_models(models, user):
     return filtered_models
 
 
-@cached(ttl=1)
+@cached(ttl=MODELS_CACHE_TTL)
 async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
     log.info("get_all_models()")
 
@@ -478,10 +479,10 @@ async def get_models(
                     "Content-Type": "application/json",
                     **(
                         {
-                            "X-OpenWebUI-User-Name": user.name,
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
+                            "X-OpenWebUI-User-Name": quote(user.name),
+                            "X-OpenWebUI-User-Id": quote(user.id),
+                            "X-OpenWebUI-User-Email": quote(user.email),
+                            "X-OpenWebUI-User-Role": quote(user.role),
                         }
                         if ENABLE_FORWARD_USER_INFO_HEADERS
                         else {}
@@ -573,10 +574,10 @@ async def verify_connection(
                 "Content-Type": "application/json",
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS
                     else {}
@@ -633,13 +634,7 @@ async def verify_connection(
             raise HTTPException(status_code=500, detail=error_detail)
 
 
-def convert_to_azure_payload(
-    url,
-    payload: dict,
-):
-    model = payload.get("model", "")
-
-    # Filter allowed parameters based on Azure OpenAI API
+def get_azure_allowed_params(api_version: str) -> set[str]:
     allowed_params = {
         "messages",
         "temperature",
@@ -669,6 +664,23 @@ def convert_to_azure_payload(
         "max_completion_tokens",
     }
 
+    try:
+        if api_version >= "2024-09-01-preview":
+            allowed_params.add("stream_options")
+    except ValueError:
+        log.debug(
+            f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
+        )
+
+    return allowed_params
+
+
+def convert_to_azure_payload(url, payload: dict, api_version: str):
+    model = payload.get("model", "")
+
+    # Filter allowed parameters based on Azure OpenAI API
+    allowed_params = get_azure_allowed_params(api_version)
+
     # Special handling for o-series models
     if model.startswith("o") and model.endswith("-mini"):
         # Convert max_tokens to max_completion_tokens for o-series models
@@ -806,10 +818,10 @@ async def generate_chat_completion(
         ),
         **(
             {
-                "X-OpenWebUI-User-Name": user.name,
-                "X-OpenWebUI-User-Id": user.id,
-                "X-OpenWebUI-User-Email": user.email,
-                "X-OpenWebUI-User-Role": user.role,
+                "X-OpenWebUI-User-Name": quote(user.name),
+                "X-OpenWebUI-User-Id": quote(user.id),
+                "X-OpenWebUI-User-Email": quote(user.email),
+                "X-OpenWebUI-User-Role": quote(user.role),
             }
             if ENABLE_FORWARD_USER_INFO_HEADERS
             else {}
@@ -817,8 +829,8 @@ async def generate_chat_completion(
     }
 
     if api_config.get("azure", False):
-        request_url, payload = convert_to_azure_payload(url, payload)
-        api_version = api_config.get("api_version", "") or "2023-03-15-preview"
+        api_version = api_config.get("api_version", "2023-03-15-preview")
+        request_url, payload = convert_to_azure_payload(url, payload, api_version)
         headers["api-key"] = key
         headers["api-version"] = api_version
         request_url = f"{request_url}/chat/completions?api-version={api_version}"
@@ -924,10 +936,10 @@ async def embeddings(request: Request, form_data: dict, user):
                 "Content-Type": "application/json",
                 **(
                     {
-                        "X-OpenWebUI-User-Name": user.name,
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
+                        "X-OpenWebUI-User-Name": quote(user.name),
+                        "X-OpenWebUI-User-Id": quote(user.id),
+                        "X-OpenWebUI-User-Email": quote(user.email),
+                        "X-OpenWebUI-User-Role": quote(user.role),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -996,10 +1008,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
             "Content-Type": "application/json",
             **(
                 {
-                    "X-OpenWebUI-User-Name": user.name,
-                    "X-OpenWebUI-User-Id": user.id,
-                    "X-OpenWebUI-User-Email": user.email,
-                    "X-OpenWebUI-User-Role": user.role,
+                    "X-OpenWebUI-User-Name": quote(user.name),
+                    "X-OpenWebUI-User-Id": quote(user.id),
+                    "X-OpenWebUI-User-Email": quote(user.email),
+                    "X-OpenWebUI-User-Role": quote(user.role),
                 }
                 if ENABLE_FORWARD_USER_INFO_HEADERS
                 else {}
@@ -1007,16 +1019,15 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
         }
 
         if api_config.get("azure", False):
+            api_version = api_config.get("api_version", "2023-03-15-preview")
             headers["api-key"] = key
-            headers["api-version"] = (
-                api_config.get("api_version", "") or "2023-03-15-preview"
-            )
+            headers["api-version"] = api_version
 
             payload = json.loads(body)
-            url, payload = convert_to_azure_payload(url, payload)
+            url, payload = convert_to_azure_payload(url, payload, api_version)
             body = json.dumps(payload).encode()
 
-            request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
+            request_url = f"{url}/{path}?api-version={api_version}"
         else:
             headers["Authorization"] = f"Bearer {key}"
             request_url = f"{url}/{path}"

+ 17 - 0
backend/open_webui/routers/retrieval.py

@@ -1747,6 +1747,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
             )
         else:
             raise Exception("No TAVILY_API_KEY found in environment variables")
+    elif engine == "exa":
+        if request.app.state.config.EXA_API_KEY:
+            return search_exa(
+                request.app.state.config.EXA_API_KEY,
+                query,
+                request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+                request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+            )
+        else:
+            raise Exception("No EXA_API_KEY found in environment variables")
     elif engine == "searchapi":
         if request.app.state.config.SEARCHAPI_API_KEY:
             return search_searchapi(
@@ -1784,6 +1794,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
             request.app.state.config.WEB_SEARCH_RESULT_COUNT,
             request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
         )
+    elif engine == "exa":
+        return search_exa(
+            request.app.state.config.EXA_API_KEY,
+            query,
+            request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+            request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+        )
     elif engine == "perplexity":
         return search_perplexity(
             request.app.state.config.PERPLEXITY_API_KEY,

+ 22 - 4
backend/open_webui/socket/main.py

@@ -1,4 +1,6 @@
 import asyncio
+import random
+
 import socketio
 import logging
 import sys
@@ -105,10 +107,26 @@ else:
 
 
 async def periodic_usage_pool_cleanup():
-    if not aquire_func():
-        log.debug("Usage pool cleanup lock already exists. Not running it.")
-        return
-    log.debug("Running periodic_usage_pool_cleanup")
+    max_retries = 2
+    retry_delay = random.uniform(
+        WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
+    )
+    for attempt in range(max_retries + 1):
+        if aquire_func():
+            break
+        else:
+            if attempt < max_retries:
+                log.debug(
+                    f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..."
+                )
+                await asyncio.sleep(retry_delay)
+            else:
+                log.warning(
+                    "Failed to acquire cleanup lock after retries. Skipping cleanup."
+                )
+                return
+
+    log.debug("Running periodic_cleanup")
     try:
         while True:
             if not renew_func():

+ 1 - 1
backend/open_webui/utils/chat.py

@@ -419,7 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
                     params[key] = value
 
             if "__user__" in sig.parameters:
-                __user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
+                __user__ = user.model_dump() if isinstance(user, UserModel) else {}
 
                 try:
                     if hasattr(function_module, "UserValves"):

+ 34 - 26
backend/open_webui/utils/middleware.py

@@ -248,6 +248,7 @@ async def chat_completion_tools_handler(
                         if tool_id
                         else f"{tool_function_name}"
                     )
+
                     if tool.get("metadata", {}).get("citation", False) or tool.get(
                         "direct", False
                     ):
@@ -718,6 +719,10 @@ def apply_params_to_form_data(form_data, model):
 
 
 async def process_chat_payload(request, form_data, user, metadata, model):
+    # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
+    # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
+    # -> Chat Files
+
     form_data = apply_params_to_form_data(form_data, model)
     log.debug(f"form_data: {form_data}")
 
@@ -804,7 +809,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
         raise e
 
     try:
-
         filter_functions = [
             Functions.get_function_by_id(filter_id)
             for filter_id in get_sorted_filter_ids(
@@ -912,7 +916,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                     request, form_data, extra_params, user, models, tools_dict
                 )
                 sources.extend(flags.get("sources", []))
-
             except Exception as e:
                 log.exception(e)
 
@@ -925,24 +928,27 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     # If context is not empty, insert it into the messages
     if len(sources) > 0:
         context_string = ""
-        citation_idx = {}
+        citation_idx_map = {}
+
         for source in sources:
             if "document" in source:
-                for doc_context, doc_meta in zip(
+                for document_text, document_metadata in zip(
                     source["document"], source["metadata"]
                 ):
                     source_name = source.get("source", {}).get("name", None)
-                    citation_id = (
-                        doc_meta.get("source", None)
+                    source_id = (
+                        document_metadata.get("source", None)
                         or source.get("source", {}).get("id", None)
                         or "N/A"
                     )
-                    if citation_id not in citation_idx:
-                        citation_idx[citation_id] = len(citation_idx) + 1
+
+                    if source_id not in citation_idx_map:
+                        citation_idx_map[source_id] = len(citation_idx_map) + 1
+
                     context_string += (
-                        f'<source id="{citation_idx[citation_id]}"'
+                        f'<source id="{citation_idx_map[source_id]}"'
                         + (f' name="{source_name}"' if source_name else "")
-                        + f">{doc_context}</source>\n"
+                        + f">{document_text}</source>\n"
                     )
 
         context_string = context_string.strip()
@@ -1370,7 +1376,7 @@ async def process_chat_response(
             return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
 
         # Handle as a background task
-        async def post_response_handler(response, events):
+        async def response_handler(response, events):
             def serialize_content_blocks(content_blocks, raw=False):
                 content = ""
 
@@ -1741,7 +1747,7 @@ async def process_chat_response(
                         },
                     )
 
-                async def stream_body_handler(response):
+                async def stream_body_handler(response, form_data):
                     nonlocal content
                     nonlocal content_blocks
 
@@ -1770,7 +1776,7 @@ async def process_chat_response(
                                 filter_functions=filter_functions,
                                 filter_type="stream",
                                 form_data=data,
-                                extra_params=extra_params,
+                                extra_params={"__body__": form_data, **extra_params},
                             )
 
                             if data:
@@ -2032,7 +2038,7 @@ async def process_chat_response(
                     if response.background:
                         await response.background()
 
-                await stream_body_handler(response)
+                await stream_body_handler(response, form_data)
 
                 MAX_TOOL_CALL_RETRIES = 10
                 tool_call_retries = 0
@@ -2181,22 +2187,24 @@ async def process_chat_response(
                     )
 
                     try:
+                        new_form_data = {
+                            "model": model_id,
+                            "stream": True,
+                            "tools": form_data["tools"],
+                            "messages": [
+                                *form_data["messages"],
+                                *convert_content_blocks_to_messages(content_blocks),
+                            ],
+                        }
+
                         res = await generate_chat_completion(
                             request,
-                            {
-                                "model": model_id,
-                                "stream": True,
-                                "tools": form_data["tools"],
-                                "messages": [
-                                    *form_data["messages"],
-                                    *convert_content_blocks_to_messages(content_blocks),
-                                ],
-                            },
+                            new_form_data,
                             user,
                         )
 
                         if isinstance(res, StreamingResponse):
-                            await stream_body_handler(res)
+                            await stream_body_handler(res, new_form_data)
                         else:
                             break
                     except Exception as e:
@@ -2427,9 +2435,9 @@ async def process_chat_response(
             if response.background is not None:
                 await response.background()
 
-        # background_tasks.add_task(post_response_handler, response, events)
+        # background_tasks.add_task(response_handler, response, events)
         task_id, _ = await create_task(
-            request, post_response_handler(response, events), id=metadata["chat_id"]
+            request, response_handler(response, events), id=metadata["chat_id"]
         )
         return {"status": True, "task_id": task_id}
 

+ 10 - 2
backend/open_webui/utils/models.py

@@ -76,8 +76,16 @@ async def get_all_base_models(request: Request, user: UserModel = None):
     return function_models + openai_models + ollama_models
 
 
-async def get_all_models(request, user: UserModel = None):
-    models = await get_all_base_models(request, user=user)
+async def get_all_models(request, refresh: bool = False, user: UserModel = None):
+    if (
+        request.app.state.MODELS
+        and request.app.state.BASE_MODELS
+        and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
+    ):
+        models = request.app.state.BASE_MODELS
+    else:
+        models = await get_all_base_models(request, user=user)
+        request.app.state.BASE_MODELS = models
 
     # If there are no models, return an empty list
     if len(models) == 0:

+ 28 - 1
backend/open_webui/utils/telemetry/setup.py

@@ -1,9 +1,13 @@
 from fastapi import FastAPI
 from opentelemetry import trace
 from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
+    OTLPSpanExporter as HttpOTLPSpanExporter,
+)
 from opentelemetry.sdk.resources import SERVICE_NAME, Resource
 from opentelemetry.sdk.trace import TracerProvider
 from sqlalchemy import Engine
+from base64 import b64encode
 
 from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
 from open_webui.utils.telemetry.instrumentors import Instrumentor
@@ -11,7 +15,11 @@ from open_webui.utils.telemetry.metrics import setup_metrics
 from open_webui.env import (
     OTEL_SERVICE_NAME,
     OTEL_EXPORTER_OTLP_ENDPOINT,
+    OTEL_EXPORTER_OTLP_INSECURE,
     ENABLE_OTEL_METRICS,
+    OTEL_BASIC_AUTH_USERNAME,
+    OTEL_BASIC_AUTH_PASSWORD,
+    OTEL_OTLP_SPAN_EXPORTER,
 )
 
 
@@ -22,8 +30,27 @@ def setup(app: FastAPI, db_engine: Engine):
             resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
         )
     )
+
+    # Add basic auth header only if both username and password are not empty
+    headers = []
+    if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
+        auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
+        auth_header = b64encode(auth_string.encode()).decode()
+        headers = [("authorization", f"Basic {auth_header}")]
+
     # otlp export
-    exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
+    if OTEL_OTLP_SPAN_EXPORTER == "http":
+        exporter = HttpOTLPSpanExporter(
+            endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
+            insecure=OTEL_EXPORTER_OTLP_INSECURE,
+            headers=headers,
+        )
+    else:
+        exporter = OTLPSpanExporter(
+            endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
+            insecure=OTEL_EXPORTER_OTLP_INSECURE,
+            headers=headers,
+        )
     trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
     Instrumentor(app=app, db_engine=db_engine).instrument()
 

+ 0 - 3
backend/open_webui/utils/tools.py

@@ -101,9 +101,6 @@ def get_tools(
 
                     def make_tool_function(function_name, token, tool_server_data):
                         async def tool_function(**kwargs):
-                            print(
-                                f"Executing tool function {function_name} with params: {kwargs}"
-                            )
                             return await execute_tool_server(
                                 token=token,
                                 url=tool_server_data["url"],

+ 5 - 5
backend/requirements.txt

@@ -1,6 +1,6 @@
 fastapi==0.115.7
-uvicorn[standard]==0.34.2
-pydantic==2.10.6
+uvicorn[standard]==0.35.0
+pydantic==2.11.7
 python-multipart==0.0.20
 
 python-socketio==5.13.0
@@ -42,8 +42,8 @@ google-genai==1.15.0
 google-generativeai==0.8.5
 tiktoken
 
-langchain==0.3.24
-langchain-community==0.3.23
+langchain==0.3.26
+langchain-community==0.3.26
 
 fake-useragent==2.1.0
 chromadb==0.6.3
@@ -114,7 +114,7 @@ pytest-docker~=3.1.1
 googleapis-common-protos==1.63.2
 google-cloud-storage==2.19.0
 
-azure-identity==1.21.0
+azure-identity==1.23.0
 azure-storage-blob==12.24.1
 
 

+ 1 - 1
backend/start_windows.bat

@@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
 SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
 
 :: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
-IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
+IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
     echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
 
     IF NOT EXIST "%KEY_FILE%" (

+ 24 - 0
docker-compose.otel.yaml

@@ -0,0 +1,24 @@
+services:
+  grafana:
+    image: grafana/otel-lgtm:latest
+    container_name: lgtm
+    ports:
+      - "3000:3000"   # Grafana UI
+      - "4317:4317"   # OTLP/gRPC
+      - "4318:4318"   # OTLP/HTTP
+    restart: unless-stopped
+
+  open-webui:
+    image: ghcr.io/open-webui/open-webui:main
+    container_name: open-webui
+    depends_on: [grafana]
+    environment:
+      - ENABLE_OTEL=true
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317 
+      - OTEL_SERVICE_NAME=open-webui
+    ports:
+      - "8088:8080"
+    networks: [default]
+
+networks:
+  default:

+ 42 - 8
package-lock.json

@@ -32,6 +32,7 @@
 				"@xyflow/svelte": "^0.1.19",
 				"async": "^3.2.5",
 				"bits-ui": "^0.21.15",
+				"chart.js": "^4.5.0",
 				"codemirror": "^6.0.1",
 				"codemirror-lang-elixir": "^4.0.0",
 				"codemirror-lang-hcl": "^0.1.0",
@@ -42,9 +43,10 @@
 				"file-saver": "^2.0.5",
 				"focus-trap": "^7.6.4",
 				"fuse.js": "^7.0.0",
+				"heic2any": "^0.0.4",
 				"highlight.js": "^11.9.0",
 				"html-entities": "^2.5.3",
-				"html2canvas-pro": "^1.5.8",
+				"html2canvas-pro": "^1.5.11",
 				"i18next": "^23.10.0",
 				"i18next-browser-languagedetector": "^7.2.0",
 				"i18next-resources-to-backend": "^1.2.0",
@@ -53,6 +55,7 @@
 				"jspdf": "^3.0.0",
 				"katex": "^0.16.22",
 				"kokoro-js": "^1.1.1",
+				"leaflet": "^1.9.4",
 				"marked": "^9.1.0",
 				"mermaid": "^11.6.0",
 				"paneforge": "^0.0.6",
@@ -70,7 +73,7 @@
 				"prosemirror-view": "^1.34.3",
 				"pyodide": "^0.27.3",
 				"socket.io-client": "^4.2.0",
-				"sortablejs": "^1.15.2",
+				"sortablejs": "^1.15.6",
 				"svelte-sonner": "^0.3.19",
 				"tippy.js": "^6.3.7",
 				"turndown": "^7.2.0",
@@ -1870,6 +1873,12 @@
 				"@jridgewell/sourcemap-codec": "^1.4.14"
 			}
 		},
+		"node_modules/@kurkle/color": {
+			"version": "0.3.4",
+			"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+			"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+			"license": "MIT"
+		},
 		"node_modules/@lezer/common": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
@@ -4723,6 +4732,18 @@
 				"url": "https://github.com/chalk/chalk?sponsor=1"
 			}
 		},
+		"node_modules/chart.js": {
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+			"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@kurkle/color": "^0.3.0"
+			},
+			"engines": {
+				"pnpm": ">=8"
+			}
+		},
 		"node_modules/check-error": {
 			"version": "1.0.3",
 			"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@@ -7295,6 +7316,12 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/heic2any": {
+			"version": "0.0.4",
+			"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
+			"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
+			"license": "MIT"
+		},
 		"node_modules/heimdalljs": {
 			"version": "0.2.6",
 			"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
@@ -7379,9 +7406,9 @@
 			}
 		},
 		"node_modules/html2canvas-pro": {
-			"version": "1.5.8",
-			"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz",
-			"integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==",
+			"version": "1.5.11",
+			"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
+			"integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
 			"license": "MIT",
 			"dependencies": {
 				"css-line-break": "^2.1.0",
@@ -8046,6 +8073,12 @@
 				"node": ">=10.13.0"
 			}
 		},
+		"node_modules/leaflet": {
+			"version": "1.9.4",
+			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+			"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+			"license": "BSD-2-Clause"
+		},
 		"node_modules/levn": {
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -11138,9 +11171,10 @@
 			}
 		},
 		"node_modules/sortablejs": {
-			"version": "1.15.2",
-			"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
-			"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
+			"version": "1.15.6",
+			"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
+			"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
+			"license": "MIT"
 		},
 		"node_modules/source-map-js": {
 			"version": "1.2.1",

+ 5 - 2
package.json

@@ -76,6 +76,7 @@
 		"@xyflow/svelte": "^0.1.19",
 		"async": "^3.2.5",
 		"bits-ui": "^0.21.15",
+		"chart.js": "^4.5.0",
 		"codemirror": "^6.0.1",
 		"codemirror-lang-elixir": "^4.0.0",
 		"codemirror-lang-hcl": "^0.1.0",
@@ -86,9 +87,10 @@
 		"file-saver": "^2.0.5",
 		"focus-trap": "^7.6.4",
 		"fuse.js": "^7.0.0",
+		"heic2any": "^0.0.4",
 		"highlight.js": "^11.9.0",
 		"html-entities": "^2.5.3",
-		"html2canvas-pro": "^1.5.8",
+		"html2canvas-pro": "^1.5.11",
 		"i18next": "^23.10.0",
 		"i18next-browser-languagedetector": "^7.2.0",
 		"i18next-resources-to-backend": "^1.2.0",
@@ -97,6 +99,7 @@
 		"jspdf": "^3.0.0",
 		"katex": "^0.16.22",
 		"kokoro-js": "^1.1.1",
+		"leaflet": "^1.9.4",
 		"marked": "^9.1.0",
 		"mermaid": "^11.6.0",
 		"paneforge": "^0.0.6",
@@ -114,7 +117,7 @@
 		"prosemirror-view": "^1.34.3",
 		"pyodide": "^0.27.3",
 		"socket.io-client": "^4.2.0",
-		"sortablejs": "^1.15.2",
+		"sortablejs": "^1.15.6",
 		"svelte-sonner": "^0.3.19",
 		"tippy.js": "^6.3.7",
 		"turndown": "^7.2.0",

+ 4 - 4
pyproject.toml

@@ -8,7 +8,7 @@ license = { file = "LICENSE" }
 dependencies = [
     "fastapi==0.115.7",
     "uvicorn[standard]==0.34.2",
-    "pydantic==2.10.6",
+    "pydantic==2.11.7",
     "python-multipart==0.0.20",
 
     "python-socketio==5.13.0",
@@ -50,8 +50,8 @@ dependencies = [
     "google-generativeai==0.8.5",
     "tiktoken",
 
-    "langchain==0.3.24",
-    "langchain-community==0.3.23",
+    "langchain==0.3.26",
+    "langchain-community==0.3.26",
 
     "fake-useragent==2.1.0",
     "chromadb==0.6.3",
@@ -138,7 +138,7 @@ requires-python = ">= 3.11, < 3.13.0a1"
 dynamic = ["version"]
 classifiers = [
     "Development Status :: 4 - Beta",
-    "License :: OSI Approved :: MIT License",
+    "License :: Other/Proprietary License",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",

+ 2 - 2
scripts/prepare-pyodide.js

@@ -74,8 +74,8 @@ async function downloadPackages() {
 			console.log('Pyodide version mismatch, removing static/pyodide directory');
 			await rmdir('static/pyodide', { recursive: true });
 		}
-	} catch (e) {
-		console.log('Pyodide package not found, proceeding with download.');
+	} catch (err) {
+		console.log('Pyodide package not found, proceeding with download.', err);
 	}
 
 	try {

+ 10 - 32
src/app.html

@@ -33,6 +33,7 @@
 		</script>
 
 		<script>
+
 			// On page load or when changing themes, best to add inline in `head` to avoid FOUC
 			(() => {
 				const metaThemeColorTag = document.querySelector('meta[name="theme-color"]');
@@ -77,28 +78,17 @@
 						}
 					}
 				});
+				const isDarkMode = document.documentElement.classList.contains('dark');
 
-				function setSplashImage() {
-					const logo = document.getElementById('logo');
-					const isDarkMode = document.documentElement.classList.contains('dark');
-
-					if (isDarkMode) {
-						const darkImage = new Image();
-						darkImage.src = '/static/splash-dark.png';
-
-						darkImage.onload = () => {
-							logo.src = '/static/splash-dark.png';
-							logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
-						};
-
-						darkImage.onerror = () => {
-							logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
-						};
-					}
-				}
+				const logo = document.createElement('img');
+				logo.id = 'logo';
+				logo.style = "position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;";
+				logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
 
-				// Runs after classes are assigned
-				window.onload = setSplashImage;
+				document.addEventListener('DOMContentLoaded', function() {
+					const splash = document.getElementById('splash-screen');
+					if (splash) splash.prepend(logo);
+				});
 			})();
 		</script>
 
@@ -120,18 +110,6 @@
 				}
 			</style>
 
-			<img
-				id="logo"
-				style="
-					position: absolute;
-					width: auto;
-					height: 6rem;
-					top: 44%;
-					left: 50%;
-					transform: translateX(-50%);
-				"
-				src="/static/splash.png"
-			/>
 
 			<div
 				style="

+ 2 - 0
src/lib/apis/auths/index.ts

@@ -347,6 +347,8 @@ export const userSignOut = async () => {
 	if (error) {
 		throw error;
 	}
+
+	sessionStorage.clear();
 	return res;
 };
 

+ 6 - 2
src/lib/apis/chats/index.ts

@@ -37,7 +37,9 @@ export const importChat = async (
 	chat: object,
 	meta: object | null,
 	pinned?: boolean,
-	folderId?: string | null
+	folderId?: string | null,
+	createdAt: number | null = null,
+	updatedAt: number | null = null
 ) => {
 	let error = null;
 
@@ -52,7 +54,9 @@ export const importChat = async (
 			chat: chat,
 			meta: meta ?? {},
 			pinned: pinned,
-			folder_id: folderId
+			folder_id: folderId,
+			created_at: createdAt ?? null,
+			updated_at: updatedAt ?? null
 		})
 	})
 		.then(async (res) => {

+ 4 - 4
src/lib/apis/configs/index.ts

@@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
 	return res;
 };
 
-export const getDirectConnectionsConfig = async (token: string) => {
+export const getConnectionsConfig = async (token: string) => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
 		method: 'GET',
 		headers: {
 			'Content-Type': 'application/json',
@@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
 	return res;
 };
 
-export const setDirectConnectionsConfig = async (token: string, config: object) => {
+export const setConnectionsConfig = async (token: string, config: object) => {
 	let error = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
+	const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
 		method: 'POST',
 		headers: {
 			'Content-Type': 'application/json',

+ 18 - 8
src/lib/apis/index.ts

@@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
 export const getModels = async (
 	token: string = '',
 	connections: object | null = null,
-	base: boolean = false
+	base: boolean = false,
+	refresh: boolean = false
 ) => {
+	const searchParams = new URLSearchParams();
+	if (refresh) {
+		searchParams.append('refresh', 'true');
+	}
+
 	let error = null;
-	const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
-		method: 'GET',
-		headers: {
-			Accept: 'application/json',
-			'Content-Type': 'application/json',
-			...(token && { authorization: `Bearer ${token}` })
+	const res = await fetch(
+		`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
+		{
+			method: 'GET',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				...(token && { authorization: `Bearer ${token}` })
+			}
 		}
-	})
+	)
 		.then(async (res) => {
 			if (!res.ok) throw await res.json();
 			return res.json();
@@ -1587,6 +1596,7 @@ export interface ModelConfig {
 }
 
 export interface ModelMeta {
+	toolIds: never[];
 	description?: string;
 	capabilities?: object;
 	profile_image_url?: string;

+ 1 - 0
src/lib/apis/users/index.ts

@@ -403,6 +403,7 @@ export const deleteUserById = async (token: string, userId: string) => {
 };
 
 type UserUpdateForm = {
+	role: string;
 	profile_image_url: string;
 	email: string;
 	name: string;

+ 4 - 34
src/lib/components/AddConnectionModal.svelte

@@ -15,6 +15,8 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Switch from '$lib/components/common/Switch.svelte';
 	import Tags from './common/Tags.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let onSubmit: Function = () => {};
 	export let onDelete: Function = () => {};
@@ -208,17 +210,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					aria-hidden="true"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -524,29 +516,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 49 - 47
src/lib/components/AddServerModal.svelte

@@ -3,6 +3,7 @@
 	import { getContext, onMount } from 'svelte';
 	const i18n = getContext('i18n');
 
+	import { settings } from '$lib/stores';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import Minus from '$lib/components/icons/Minus.svelte';
@@ -14,6 +15,8 @@
 	import { getToolServerData } from '$lib/apis';
 	import { verifyToolServerConnection } from '$lib/apis/configs';
 	import AccessControl from './workspace/common/AccessControl.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let onSubmit: Function = () => {};
 	export let onDelete: Function = () => {};
@@ -153,29 +156,21 @@
 <Modal size="sm" bind:show>
 	<div>
 		<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
-			<div class=" text-lg font-medium self-center font-primary">
+			<h1 class=" text-lg font-medium self-center font-primary">
 				{#if edit}
 					{$i18n.t('Edit Connection')}
 				{:else}
 					{$i18n.t('Add Connection')}
 				{/if}
-			</div>
+			</h1>
 			<button
 				class="self-center"
+				aria-label={$i18n.t('Close Configure Connection Modal')}
 				on:click={() => {
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -192,12 +187,17 @@
 						<div class="flex gap-2">
 							<div class="flex flex-col w-full">
 								<div class="flex justify-between mb-0.5">
-									<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
+									<label
+										for="api-base-url"
+										class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+										>{$i18n.t('URL')}</label
+									>
 								</div>
 
 								<div class="flex flex-1 items-center">
 									<input
-										class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+										id="api-base-url"
+										class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 										type="text"
 										bind:value={url}
 										placeholder={$i18n.t('API Base URL')}
@@ -214,6 +214,7 @@
 											on:click={() => {
 												verifyHandler();
 											}}
+											aria-label={$i18n.t('Verify Connection')}
 											type="button"
 										>
 											<svg
@@ -221,6 +222,7 @@
 												viewBox="0 0 20 20"
 												fill="currentColor"
 												class="w-4 h-4"
+												aria-hidden="true"
 											>
 												<path
 													fill-rule="evenodd"
@@ -237,9 +239,13 @@
 								</div>
 
 								<div class="flex-1 flex items-center">
+									<label for="url-or-path" class="sr-only"
+										>{$i18n.t('openapi.json URL or Path')}</label
+									>
 									<input
-										class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+										class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 										type="text"
+										id="url-or-path"
 										bind:value={path}
 										placeholder={$i18n.t('openapi.json URL or Path')}
 										autocomplete="off"
@@ -249,7 +255,9 @@
 							</div>
 						</div>
 
-						<div class="text-xs text-gray-500 mt-1">
+						<div
+							class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+						>
 							{$i18n.t(`WebUI will make requests to "{{url}}"`, {
 								url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
 							})}
@@ -257,12 +265,17 @@
 
 						<div class="flex gap-2 mt-2">
 							<div class="flex flex-col w-full">
-								<div class="  text-xs text-gray-500">{$i18n.t('Auth')}</div>
+								<label
+									for="select-bearer-or-session"
+									class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+									>{$i18n.t('Auth')}</label
+								>
 
 								<div class="flex gap-2">
 									<div class="flex-shrink-0 self-start">
 										<select
-											class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
+											id="select-bearer-or-session"
+											class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 											bind:value={auth_type}
 										>
 											<option value="bearer">Bearer</option>
@@ -273,13 +286,14 @@
 									<div class="flex flex-1 items-center">
 										{#if auth_type === 'bearer'}
 											<SensitiveInput
-												className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
 												bind:value={key}
 												placeholder={$i18n.t('API Key')}
 												required={false}
 											/>
 										{:else if auth_type === 'session'}
-											<div class="text-xs text-gray-500 self-center translate-y-[1px]">
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
 												{$i18n.t('Forwards system user session credentials to authenticate')}
 											</div>
 										{/if}
@@ -293,11 +307,16 @@
 
 							<div class="flex gap-2">
 								<div class="flex flex-col w-full">
-									<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
+									<label
+										for="enter-name"
+										class={`mb-0.5 text-xs" ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+										>{$i18n.t('Name')}</label
+									>
 
 									<div class="flex-1">
 										<input
-											class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+											id="enter-name"
+											class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 											type="text"
 											bind:value={name}
 											placeholder={$i18n.t('Enter name')}
@@ -309,11 +328,16 @@
 							</div>
 
 							<div class="flex flex-col w-full mt-2">
-								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
+								<label
+									for="description"
+									class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
+									>{$i18n.t('Description')}</label
+								>
 
 								<div class="flex-1">
 									<input
-										class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+										id="description"
+										class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 										type="text"
 										bind:value={description}
 										placeholder={$i18n.t('Enter description')}
@@ -357,29 +381,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 4 - 10
src/lib/components/ChangelogModal.svelte

@@ -9,6 +9,7 @@
 
 	import Modal from './common/Modal.svelte';
 	import { updateUserSettings } from '$lib/apis/users';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -36,18 +37,11 @@
 					localStorage.version = $config.version;
 					show = false;
 				}}
+				aria-label={$i18n.t('Close')}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
+				<XMark className={'size-5'}>
 					<p class="sr-only">{$i18n.t('Close')}</p>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				</XMark>
 			</button>
 		</div>
 		<div class="flex items-center mt-1">

+ 4 - 33
src/lib/components/ImportModal.svelte

@@ -3,7 +3,9 @@
 	import { getContext, onMount } from 'svelte';
 	const i18n = getContext('i18n');
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 	import { extractFrontmatter } from '$lib/utils';
 
 	export let show = false;
@@ -69,16 +71,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -120,29 +113,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 95 - 38
src/lib/components/admin/Evaluations/FeedbackModal.svelte

@@ -2,16 +2,42 @@
 	import Modal from '$lib/components/common/Modal.svelte';
 	import { getContext } from 'svelte';
 	const i18n = getContext('i18n');
+	import XMark from '$lib/components/icons/XMark.svelte';
+	import { getFeedbackById } from '$lib/apis/evaluations';
+	import { toast } from 'svelte-sonner';
+	import Spinner from '$lib/components/common/Spinner.svelte';
 
 	export let show = false;
 	export let selectedFeedback = null;
 
 	export let onClose: () => void = () => {};
 
+	let loaded = false;
+
+	let feedbackData = null;
+
 	const close = () => {
 		show = false;
 		onClose();
 	};
+
+	const init = async () => {
+		loaded = false;
+		feedbackData = null;
+		if (selectedFeedback) {
+			feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => {
+				toast.error(err);
+				return null;
+			});
+
+			console.log('Feedback Data:', selectedFeedback, feedbackData);
+		}
+		loaded = true;
+	};
+
+	$: if (show) {
+		init();
+	}
 </script>
 
 <Modal size="sm" bind:show>
@@ -22,58 +48,89 @@
 					{$i18n.t('Feedback Details')}
 				</div>
 				<button class="self-center" on:click={close} aria-label="Close">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 20 20"
-						fill="currentColor"
-						class="w-5 h-5"
-					>
-						<path
-							d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-						/>
-					</svg>
+					<XMark className={'size-5'} />
 				</button>
 			</div>
 
 			<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
-				<div class="flex flex-col w-full">
-					<div class="flex flex-col w-full mb-2">
-						<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
+				{#if loaded}
+					<div class="flex flex-col w-full">
+						{#if feedbackData}
+							{@const messageId = feedbackData?.meta?.message_id}
+							{@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
+
+							{#if messages[messages[messageId]?.parentId]}
+								<div class="flex flex-col w-full mb-2">
+									<div class="mb-1 text-xs text-gray-500">{$i18n.t('Prompt')}</div>
+
+									<div class="flex-1 text-xs whitespace-pre-line break-words">
+										<span>{messages[messages[messageId]?.parentId]?.content || '-'}</span>
+									</div>
+								</div>
+							{/if}
+
+							{#if messages[messageId]}
+								<div class="flex flex-col w-full mb-2">
+									<div class="mb-1 text-xs text-gray-500">{$i18n.t('Response')}</div>
+									<div
+										class="flex-1 text-xs whitespace-pre-line break-words max-h-32 overflow-y-auto"
+									>
+										<span>{messages[messageId]?.content || '-'}</span>
+									</div>
+								</div>
+							{/if}
+						{/if}
+
+						<div class="flex flex-col w-full mb-2">
+							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
 
-						<div class="flex-1">
-							<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
+							<div class="flex-1 text-xs">
+								<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
+							</div>
 						</div>
-					</div>
-					<div class="flex flex-col w-full mb-2">
-						<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
+						<div class="flex flex-col w-full mb-2">
+							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
 
-						<div class="flex-1">
-							<span>{selectedFeedback?.data?.reason || '-'}</span>
+							<div class="flex-1 text-xs">
+								<span>{selectedFeedback?.data?.reason || '-'}</span>
+							</div>
+						</div>
+
+						<div class="flex flex-col w-full mb-2">
+							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Comment')}</div>
+
+							<div class="flex-1 text-xs">
+								<span>{selectedFeedback?.data?.comment || '-'}</span>
+							</div>
 						</div>
-					</div>
 
-					<div class="mb-2">
 						{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
-							<div class="flex flex-wrap gap-1 mt-1">
-								{#each selectedFeedback?.data?.tags as tag}
-									<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">{tag}</span
-									>
-								{/each}
+							<div class="mb-2 -mx-1">
+								<div class="flex flex-wrap gap-1 mt-1">
+									{#each selectedFeedback?.data?.tags as tag}
+										<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
+											>{tag}</span
+										>
+									{/each}
+								</div>
 							</div>
-						{:else}
-							<span>-</span>
 						{/if}
+
+						<div class="flex justify-end pt-2">
+							<button
+								class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
+								type="button"
+								on:click={close}
+							>
+								{$i18n.t('Close')}
+							</button>
+						</div>
 					</div>
-					<div class="flex justify-end pt-3">
-						<button
-							class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
-							type="button"
-							on:click={close}
-						>
-							{$i18n.t('Close')}
-						</button>
+				{:else}
+					<div class="flex items-center justify-center w-full h-32">
+						<Spinner className={'size-5'} />
 					</div>
-				</div>
+				{/if}
 			</div>
 		</div>
 	{/if}

+ 2 - 2
src/lib/components/admin/Evaluations/Feedbacks.svelte

@@ -305,7 +305,7 @@
 			<tbody class="">
 				{#each paginatedFeedbacks as feedback (feedback.id)}
 					<tr
-						class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
+						class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
 						on:click={() => openFeedbackModal(feedback)}
 					>
 						<td class=" py-0.5 text-right font-semibold">
@@ -369,7 +369,7 @@
 							{dayjs(feedback.updated_at * 1000).fromNow()}
 						</td>
 
-						<td class=" px-3 py-1 text-right font-semibold">
+						<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
 							<FeedbackMenu
 								on:delete={(e) => {
 									deleteFeedbackHandler(feedback.id);

+ 6 - 6
src/lib/components/admin/Evaluations/Leaderboard.svelte

@@ -11,7 +11,7 @@
 
 	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
-	import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
+	import Search from '$lib/components/icons/Search.svelte';
 
 	import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
 	import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
@@ -77,7 +77,7 @@
 	let showLeaderboardModal = false;
 	let selectedModel = null;
 
-	const openFeedbackModal = (model) => {
+	const openLeaderboardModelModal = (model) => {
 		showLeaderboardModal = true;
 		selectedModel = model;
 	};
@@ -350,7 +350,7 @@
 		<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
 			<div class="flex flex-1">
 				<div class=" self-center ml-1 mr-3">
-					<MagnifyingGlass className="size-3" />
+					<Search className="size-3" />
 				</div>
 				<input
 					class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
@@ -371,7 +371,7 @@
 	{#if loadingLeaderboard}
 		<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
 			<div class="m-auto">
-				<Spinner />
+				<Spinner className="size-5" />
 			</div>
 		</div>
 	{/if}
@@ -504,8 +504,8 @@
 			<tbody class="">
 				{#each sortedModels as model, modelIdx (model.id)}
 					<tr
-						class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
-						on:click={() => openFeedbackModal(model)}
+						class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
+						on:click={() => openLeaderboardModelModal(model)}
 					>
 						<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
 							<div class=" line-clamp-1">

+ 6 - 14
src/lib/components/admin/Evaluations/LeaderboardModal.svelte

@@ -6,6 +6,7 @@
 	export let feedbacks = [];
 	export let onClose: () => void = () => {};
 	const i18n = getContext('i18n');
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const close = () => {
 		show = false;
@@ -37,25 +38,16 @@
 				{model.name}
 			</div>
 			<button class="self-center" on:click={close} aria-label="Close">
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 		<div class="px-5 pb-4 dark:text-gray-200">
 			<div class="mb-2">
 				{#if topTags.length}
-					<div class="flex flex-wrap gap-1 mt-1">
+					<div class="flex flex-wrap gap-1 mt-1 -mx-1">
 						{#each topTags as tagInfo}
-							<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">
-								{tagInfo.tag} <span class="text-gray-500">({tagInfo.count})</span>
+							<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
+								{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
 							</span>
 						{/each}
 					</div>
@@ -63,7 +55,7 @@
 					<span>-</span>
 				{/if}
 			</div>
-			<div class="flex justify-end pt-3">
+			<div class="flex justify-end pt-2">
 				<button
 					class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
 					type="button"

+ 15 - 5
src/lib/components/admin/Functions.svelte

@@ -135,7 +135,9 @@
 			models.set(
 				await getModels(
 					localStorage.token,
-					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
+					false,
+					true
 				)
 			);
 		}
@@ -161,7 +163,9 @@
 			models.set(
 				await getModels(
 					localStorage.token,
-					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
+					false,
+					true
 				)
 			);
 		}
@@ -413,7 +417,9 @@
 									await getModels(
 										localStorage.token,
 										$config?.features?.enable_direct_connections &&
-											($settings?.directConnections ?? null)
+											($settings?.directConnections ?? null),
+										false,
+										true
 									)
 								);
 							}}
@@ -559,7 +565,9 @@
 		models.set(
 			await getModels(
 				localStorage.token,
-				$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+				$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
+				false,
+				true
 			)
 		);
 	}}
@@ -585,7 +593,9 @@
 			models.set(
 				await getModels(
 					localStorage.token,
-					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+					$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
+					false,
+					true
 				)
 			);
 		};

+ 2 - 27
src/lib/components/admin/Settings/Audio.svelte

@@ -12,6 +12,7 @@
 	} from '$lib/apis/audio';
 	import { config, settings } from '$lib/stores';
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
 
 	import { TTS_RESPONSE_SPLIT } from '$lib/types';
@@ -373,33 +374,7 @@
 							>
 								{#if STT_WHISPER_MODEL_LOADING}
 									<div class="self-center">
-										<svg
-											class=" w-4 h-4"
-											viewBox="0 0 24 24"
-											fill="currentColor"
-											xmlns="http://www.w3.org/2000/svg"
-										>
-											<style>
-												.spinner_ajPY {
-													transform-origin: center;
-													animation: spinner_AtaB 0.75s infinite linear;
-												}
-
-												@keyframes spinner_AtaB {
-													100% {
-														transform: rotate(360deg);
-													}
-												}
-											</style>
-											<path
-												d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-												opacity=".25"
-											/>
-											<path
-												d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-												class="spinner_ajPY"
-											/>
-										</svg>
+										<Spinner />
 									</div>
 								{:else}
 									<svg

+ 160 - 137
src/lib/components/admin/Settings/Connections.svelte

@@ -7,7 +7,7 @@
 	import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
 	import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
 	import { getModels as _getModels } from '$lib/apis';
-	import { getDirectConnectionsConfig, setDirectConnectionsConfig } from '$lib/apis/configs';
+	import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs';
 
 	import { config, models, settings, user } from '$lib/stores';
 
@@ -25,7 +25,9 @@
 	const getModels = async () => {
 		const models = await _getModels(
 			localStorage.token,
-			$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
+			$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
+			false,
+			true
 		);
 		return models;
 	};
@@ -41,7 +43,7 @@
 	let ENABLE_OPENAI_API: null | boolean = null;
 	let ENABLE_OLLAMA_API: null | boolean = null;
 
-	let directConnectionsConfig = null;
+	let connectionsConfig = null;
 
 	let pipelineUrls = {};
 	let showAddOpenAIConnectionModal = false;
@@ -104,15 +106,13 @@
 		}
 	};
 
-	const updateDirectConnectionsHandler = async () => {
-		const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
-			(error) => {
-				toast.error(`${error}`);
-			}
-		);
+	const updateConnectionsHandler = async () => {
+		const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
+			toast.error(`${error}`);
+		});
 
 		if (res) {
-			toast.success($i18n.t('Direct Connections settings updated'));
+			toast.success($i18n.t('Connections settings updated'));
 			await models.set(await getModels());
 		}
 	};
@@ -148,7 +148,7 @@
 					openaiConfig = await getOpenAIConfig(localStorage.token);
 				})(),
 				(async () => {
-					directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
+					connectionsConfig = await getConnectionsConfig(localStorage.token);
 				})()
 			]);
 
@@ -215,36 +215,103 @@
 
 <form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
 	<div class=" overflow-y-scroll scrollbar-hidden h-full">
-		{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && directConnectionsConfig !== null}
-			<div class="my-2">
-				<div class="mt-2 space-y-2 pr-1.5">
-					<div class="flex justify-between items-center text-sm">
-						<div class="  font-medium">{$i18n.t('OpenAI API')}</div>
+		{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
+			<div class="mb-3.5">
+				<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
+
+				<hr class=" border-gray-100 dark:border-gray-850 my-2" />
+
+				<div class="my-2">
+					<div class="mt-2 space-y-2">
+						<div class="flex justify-between items-center text-sm">
+							<div class="  font-medium">{$i18n.t('OpenAI API')}</div>
+
+							<div class="flex items-center">
+								<div class="">
+									<Switch
+										bind:state={ENABLE_OPENAI_API}
+										on:change={async () => {
+											updateOpenAIHandler();
+										}}
+									/>
+								</div>
+							</div>
+						</div>
 
-						<div class="flex items-center">
+						{#if ENABLE_OPENAI_API}
 							<div class="">
-								<Switch
-									bind:state={ENABLE_OPENAI_API}
-									on:change={async () => {
-										updateOpenAIHandler();
-									}}
-								/>
+								<div class="flex justify-between items-center">
+									<div class="font-medium text-xs">{$i18n.t('Manage OpenAI API Connections')}</div>
+
+									<Tooltip content={$i18n.t(`Add Connection`)}>
+										<button
+											class="px-1"
+											on:click={() => {
+												showAddOpenAIConnectionModal = true;
+											}}
+											type="button"
+										>
+											<Plus />
+										</button>
+									</Tooltip>
+								</div>
+
+								<div class="flex flex-col gap-1.5 mt-1.5">
+									{#each OPENAI_API_BASE_URLS as url, idx}
+										<OpenAIConnection
+											pipeline={pipelineUrls[url] ? true : false}
+											bind:url
+											bind:key={OPENAI_API_KEYS[idx]}
+											bind:config={OPENAI_API_CONFIGS[idx]}
+											onSubmit={() => {
+												updateOpenAIHandler();
+											}}
+											onDelete={() => {
+												OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
+													(url, urlIdx) => idx !== urlIdx
+												);
+												OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
+
+												let newConfig = {};
+												OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
+													newConfig[newIdx] =
+														OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
+												});
+												OPENAI_API_CONFIGS = newConfig;
+												updateOpenAIHandler();
+											}}
+										/>
+									{/each}
+								</div>
 							</div>
-						</div>
+						{/if}
 					</div>
+				</div>
 
-					{#if ENABLE_OPENAI_API}
-						<hr class=" border-gray-100 dark:border-gray-850" />
+				<div class=" my-2">
+					<div class="flex justify-between items-center text-sm mb-2">
+						<div class="  font-medium">{$i18n.t('Ollama API')}</div>
+
+						<div class="mt-1">
+							<Switch
+								bind:state={ENABLE_OLLAMA_API}
+								on:change={async () => {
+									updateOllamaHandler();
+								}}
+							/>
+						</div>
+					</div>
 
+					{#if ENABLE_OLLAMA_API}
 						<div class="">
 							<div class="flex justify-between items-center">
-								<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div>
+								<div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
 
 								<Tooltip content={$i18n.t(`Add Connection`)}>
 									<button
 										class="px-1"
 										on:click={() => {
-											showAddOpenAIConnectionModal = true;
+											showAddOllamaConnectionModal = true;
 										}}
 										type="button"
 									>
@@ -253,133 +320,89 @@
 								</Tooltip>
 							</div>
 
-							<div class="flex flex-col gap-1.5 mt-1.5">
-								{#each OPENAI_API_BASE_URLS as url, idx}
-									<OpenAIConnection
-										pipeline={pipelineUrls[url] ? true : false}
-										bind:url
-										bind:key={OPENAI_API_KEYS[idx]}
-										bind:config={OPENAI_API_CONFIGS[idx]}
-										onSubmit={() => {
-											updateOpenAIHandler();
-										}}
-										onDelete={() => {
-											OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
-												(url, urlIdx) => idx !== urlIdx
-											);
-											OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
-
-											let newConfig = {};
-											OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
-												newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
-											});
-											OPENAI_API_CONFIGS = newConfig;
-											updateOpenAIHandler();
-										}}
-									/>
-								{/each}
+							<div class="flex w-full gap-1.5">
+								<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
+									{#each OLLAMA_BASE_URLS as url, idx}
+										<OllamaConnection
+											bind:url
+											bind:config={OLLAMA_API_CONFIGS[idx]}
+											{idx}
+											onSubmit={() => {
+												updateOllamaHandler();
+											}}
+											onDelete={() => {
+												OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
+
+												let newConfig = {};
+												OLLAMA_BASE_URLS.forEach((url, newIdx) => {
+													newConfig[newIdx] =
+														OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
+												});
+												OLLAMA_API_CONFIGS = newConfig;
+											}}
+										/>
+									{/each}
+								</div>
+							</div>
+
+							<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+								{$i18n.t('Trouble accessing Ollama?')}
+								<a
+									class=" text-gray-300 font-medium underline"
+									href="https://github.com/open-webui/open-webui#troubleshooting"
+									target="_blank"
+								>
+									{$i18n.t('Click here for help.')}
+								</a>
 							</div>
 						</div>
 					{/if}
 				</div>
-			</div>
-
-			<hr class=" border-gray-100 dark:border-gray-850" />
-
-			<div class="pr-1.5 my-2">
-				<div class="flex justify-between items-center text-sm mb-2">
-					<div class="  font-medium">{$i18n.t('Ollama API')}</div>
-
-					<div class="mt-1">
-						<Switch
-							bind:state={ENABLE_OLLAMA_API}
-							on:change={async () => {
-								updateOllamaHandler();
-							}}
-						/>
-					</div>
-				</div>
-
-				{#if ENABLE_OLLAMA_API}
-					<hr class=" border-gray-100 dark:border-gray-850 my-2" />
 
-					<div class="">
-						<div class="flex justify-between items-center">
-							<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
+				<div class="my-2">
+					<div class="flex justify-between items-center text-sm">
+						<div class="  font-medium">{$i18n.t('Direct Connections')}</div>
 
-							<Tooltip content={$i18n.t(`Add Connection`)}>
-								<button
-									class="px-1"
-									on:click={() => {
-										showAddOllamaConnectionModal = true;
+						<div class="flex items-center">
+							<div class="">
+								<Switch
+									bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
+									on:change={async () => {
+										updateConnectionsHandler();
 									}}
-									type="button"
-								>
-									<Plus />
-								</button>
-							</Tooltip>
-						</div>
-
-						<div class="flex w-full gap-1.5">
-							<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
-								{#each OLLAMA_BASE_URLS as url, idx}
-									<OllamaConnection
-										bind:url
-										bind:config={OLLAMA_API_CONFIGS[idx]}
-										{idx}
-										onSubmit={() => {
-											updateOllamaHandler();
-										}}
-										onDelete={() => {
-											OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
-
-											let newConfig = {};
-											OLLAMA_BASE_URLS.forEach((url, newIdx) => {
-												newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
-											});
-											OLLAMA_API_CONFIGS = newConfig;
-										}}
-									/>
-								{/each}
+								/>
 							</div>
 						</div>
+					</div>
 
-						<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
-							{$i18n.t('Trouble accessing Ollama?')}
-							<a
-								class=" text-gray-300 font-medium underline"
-								href="https://github.com/open-webui/open-webui#troubleshooting"
-								target="_blank"
-							>
-								{$i18n.t('Click here for help.')}
-							</a>
-						</div>
+					<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+						{$i18n.t(
+							'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
+						)}
 					</div>
-				{/if}
-			</div>
+				</div>
 
-			<hr class=" border-gray-100 dark:border-gray-850" />
+				<hr class=" border-gray-100 dark:border-gray-850 my-2" />
 
-			<div class="pr-1.5 my-2">
-				<div class="flex justify-between items-center text-sm">
-					<div class="  font-medium">{$i18n.t('Direct Connections')}</div>
+				<div class="my-2">
+					<div class="flex justify-between items-center text-sm">
+						<div class=" text-xs font-medium">{$i18n.t('Cache Base Model List')}</div>
 
-					<div class="flex items-center">
-						<div class="">
-							<Switch
-								bind:state={directConnectionsConfig.ENABLE_DIRECT_CONNECTIONS}
-								on:change={async () => {
-									updateDirectConnectionsHandler();
-								}}
-							/>
+						<div class="flex items-center">
+							<div class="">
+								<Switch
+									bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
+									on:change={async () => {
+										updateConnectionsHandler();
+									}}
+								/>
+							</div>
 						</div>
 					</div>
-				</div>
 
-				<div class="mt-1.5">
-					<div class="text-xs text-gray-500">
+					<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
 						{$i18n.t(
-							'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
+							'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.'
 						)}
 					</div>
 				</div>

+ 2 - 10
src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte

@@ -5,6 +5,7 @@
 
 	import Modal from '$lib/components/common/Modal.svelte';
 	import ManageOllama from '../Models/Manage/ManageOllama.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let show = false;
 	export let urlIdx: number | null = null;
@@ -26,16 +27,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 

+ 7 - 33
src/lib/components/admin/Settings/Documents.svelte

@@ -90,10 +90,6 @@
 			return;
 		}
 
-		if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) {
-			toast.error($i18n.t('OpenAI URL/Key required.'));
-			return;
-		}
 		if (
 			embeddingEngine === 'azure_openai' &&
 			(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
@@ -731,7 +727,11 @@
 										required
 									/>
 
-									<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
+									<SensitiveInput
+										placeholder={$i18n.t('API Key')}
+										bind:value={OpenAIKey}
+										required={false}
+									/>
 								</div>
 							{:else if embeddingEngine === 'ollama'}
 								<div class="my-0.5 flex gap-2 pr-2">
@@ -808,33 +808,7 @@
 											>
 												{#if updateEmbeddingModelLoading}
 													<div class="self-center">
-														<svg
-															class=" w-4 h-4"
-															viewBox="0 0 24 24"
-															fill="currentColor"
-															xmlns="http://www.w3.org/2000/svg"
-														>
-															<style>
-																.spinner_ajPY {
-																	transform-origin: center;
-																	animation: spinner_AtaB 0.75s infinite linear;
-																}
-
-																@keyframes spinner_AtaB {
-																	100% {
-																		transform: rotate(360deg);
-																	}
-																}
-															</style>
-															<path
-																d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-																opacity=".25"
-															/>
-															<path
-																d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-																class="spinner_ajPY"
-															/>
-														</svg>
+														<Spinner />
 													</div>
 												{:else}
 													<svg
@@ -1272,7 +1246,7 @@
 		</div>
 	{:else}
 		<div class="flex items-center justify-center h-full">
-			<Spinner />
+			<Spinner className="size-5" />
 		</div>
 	{/if}
 </form>

+ 4 - 33
src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte

@@ -3,6 +3,7 @@
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import { models } from '$lib/stores';
 	import Plus from '$lib/components/icons/Plus.svelte';
@@ -11,6 +12,7 @@
 	import { toast } from 'svelte-sonner';
 	import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
 	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let show = false;
 	export let edit = false;
@@ -141,16 +143,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -406,29 +399,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 14 - 10
src/lib/components/admin/Settings/General.svelte

@@ -90,7 +90,9 @@
 	};
 
 	onMount(async () => {
-		checkForVersionUpdates();
+		if (!$config?.offline_mode) {
+			checkForVersionUpdates();
+		}
 
 		await Promise.all([
 			(async () => {
@@ -160,15 +162,17 @@
 								</button>
 							</div>
 
-							<button
-								class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
-								type="button"
-								on:click={() => {
-									checkForVersionUpdates();
-								}}
-							>
-								{$i18n.t('Check for updates')}
-							</button>
+							{#if !$config?.offline_mode}
+								<button
+									class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
+									type="button"
+									on:click={() => {
+										checkForVersionUpdates();
+									}}
+								>
+									{$i18n.t('Check for updates')}
+								</button>
+							{/if}
 						</div>
 					</div>
 

+ 9 - 29
src/lib/components/admin/Settings/Images.svelte

@@ -13,9 +13,11 @@
 		updateConfig,
 		verifyConfigUrl
 	} from '$lib/apis/images';
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
 	import Switch from '$lib/components/common/Switch.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Textarea from '$lib/components/common/Textarea.svelte';
 	const dispatch = createEventDispatcher();
 
 	const i18n = getContext('i18n');
@@ -504,7 +506,7 @@
 						<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
 
 						{#if config.comfyui.COMFYUI_WORKFLOW}
-							<textarea
+							<Textarea
 								class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
 								rows="10"
 								bind:value={config.comfyui.COMFYUI_WORKFLOW}
@@ -533,7 +535,7 @@
 								/>
 
 								<button
-									class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
+									class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-50 border border-dashed border-gray-50 dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
 									type="button"
 									on:click={() => {
 										document.getElementById('upload-comfyui-workflow-input')?.click();
@@ -555,10 +557,10 @@
 
 							<div class="text-xs flex flex-col gap-1.5">
 								{#each requiredWorkflowNodes as node}
-									<div class="flex w-full items-center border dark:border-gray-850 rounded-lg">
+									<div class="flex w-full items-center">
 										<div class="shrink-0">
 											<div
-												class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200"
+												class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center bg-green-500/10 text-green-700 dark:text-green-200"
 											>
 												{node.type}{node.type === 'prompt' ? '*' : ''}
 											</div>
@@ -566,7 +568,7 @@
 										<div class="">
 											<Tooltip content="Input Key (e.g. text, unet_name, steps)">
 												<input
-													class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850"
+													class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r border-gray-50 dark:border-gray-850"
 													placeholder="Key"
 													bind:value={node.key}
 													required
@@ -580,7 +582,7 @@
 												placement="top-start"
 											>
 												<input
-													class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden"
+													class="w-full py-1 px-4 text-xs bg-transparent outline-hidden"
 													placeholder="Node Ids"
 													bind:value={node.node_ids}
 												/>
@@ -711,29 +713,7 @@
 
 			{#if loading}
 				<div class="ml-2 self-center">
-					<svg
-						class=" w-4 h-4"
-						viewBox="0 0 24 24"
-						fill="currentColor"
-						xmlns="http://www.w3.org/2000/svg"
-						><style>
-							.spinner_ajPY {
-								transform-origin: center;
-								animation: spinner_AtaB 0.75s infinite linear;
-							}
-							@keyframes spinner_AtaB {
-								100% {
-									transform: rotate(360deg);
-								}
-							}
-						</style><path
-							d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-							opacity=".25"
-						/><path
-							d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-							class="spinner_ajPY"
-						/></svg
-					>
+					<Spinner />
 				</div>
 			{/if}
 		</button>

+ 3 - 3
src/lib/components/admin/Settings/Interface.svelte

@@ -390,7 +390,7 @@
 
 				<div class="mb-2.5">
 					<div class="flex w-full justify-between">
-						<div class=" self-center text-sm">
+						<div class=" self-center text-xs">
 							{$i18n.t('Banners')}
 						</div>
 
@@ -432,7 +432,7 @@
 				{#if $user?.role === 'admin'}
 					<div class=" space-y-3">
 						<div class="flex w-full justify-between mb-2">
-							<div class=" self-center text-sm">
+							<div class=" self-center text-xs">
 								{$i18n.t('Default Prompt Suggestions')}
 							</div>
 
@@ -636,6 +636,6 @@
 	</form>
 {:else}
 	<div class=" h-full w-full flex justify-center items-center">
-		<Spinner />
+		<Spinner className="size-5" />
 	</div>
 {/if}

+ 17 - 16
src/lib/components/admin/Settings/Interface/Banners.svelte

@@ -1,7 +1,9 @@
 <script lang="ts">
 	import Switch from '$lib/components/common/Switch.svelte';
+	import Textarea from '$lib/components/common/Textarea.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 	import Sortable from 'sortablejs';
 	import { getContext } from 'svelte';
 	const i18n = getContext('i18n');
@@ -23,6 +25,13 @@
 		});
 	};
 
+	const classNames: Record<string, string> = {
+		info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
+		success: 'bg-green-500/20 text-green-700 dark:text-green-200',
+		warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
+		error: 'bg-red-500/20 text-red-700 dark:text-red-200'
+	};
+
 	$: if (banners) {
 		init();
 	}
@@ -44,14 +53,14 @@
 	};
 </script>
 
-<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
+<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
 	{#each banners as banner, bannerIdx (banner.id)}
-		<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
+		<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
 			<EllipsisVertical className="size-4 cursor-move item-handle" />
 
-			<div class="flex flex-row flex-1 gap-2 items-center">
+			<div class="flex flex-row flex-1 gap-2 items-start">
 				<select
-					class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
+					class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
 					bind:value={banner.type}
 					required
 				>
@@ -64,10 +73,11 @@
 					<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
 				</select>
 
-				<input
-					class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
+				<Textarea
+					className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
 					placeholder={$i18n.t('Content')}
 					bind:value={banner.content}
+					maxSize={100}
 				/>
 
 				<div class="relative -left-2">
@@ -85,16 +95,7 @@
 					banners = banners;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-4 h-4"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-4'} />
 			</button>
 		</div>
 	{/each}

+ 1 - 1
src/lib/components/admin/Settings/Models.svelte

@@ -563,6 +563,6 @@
 	{/if}
 {:else}
 	<div class=" h-full w-full flex justify-center items-center">
-		<Spinner />
+		<Spinner className="size-5" />
 	</div>
 {/if}

+ 4 - 34
src/lib/components/admin/Settings/Models/ConfigureModelsModal.svelte

@@ -18,6 +18,7 @@
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
 	import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let show = false;
 	export let initHandler = () => {};
@@ -129,16 +130,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -278,29 +270,7 @@
 
 								{#if loading}
 									<div class="ml-2 self-center">
-										<svg
-											class=" w-4 h-4"
-											viewBox="0 0 24 24"
-											fill="currentColor"
-											xmlns="http://www.w3.org/2000/svg"
-											><style>
-												.spinner_ajPY {
-													transform-origin: center;
-													animation: spinner_AtaB 0.75s infinite linear;
-												}
-												@keyframes spinner_AtaB {
-													100% {
-														transform: rotate(360deg);
-													}
-												}
-											</style><path
-												d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-												opacity=".25"
-											/><path
-												d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-												class="spinner_ajPY"
-											/></svg
-										>
+										<Spinner />
 									</div>
 								{/if}
 							</button>
@@ -308,7 +278,7 @@
 					</form>
 				{:else}
 					<div>
-						<Spinner />
+						<Spinner className="size-5" />
 					</div>
 				{/if}
 			</div>

+ 1 - 1
src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte

@@ -1057,6 +1057,6 @@
 	</div>
 {:else}
 	<div class="flex justify-center items-center w-full h-full py-3">
-		<Spinner />
+		<Spinner className="size-5" />
 	</div>
 {/if}

+ 2 - 10
src/lib/components/admin/Settings/Models/ManageModelsModal.svelte

@@ -7,6 +7,7 @@
 
 	import { user } from '$lib/stores';
 
+	import XMark from '$lib/components/icons/XMark.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import ManageOllama from './Manage/ManageOllama.svelte';
 	import { getOllamaConfig } from '$lib/apis/ollama';
@@ -48,16 +49,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 

+ 1 - 1
src/lib/components/admin/Settings/WebSearch.svelte

@@ -141,6 +141,7 @@
 												placeholder={$i18n.t('Enter Searxng Query URL')}
 												bind:value={webConfig.SEARXNG_QUERY_URL}
 												autocomplete="off"
+												required
 											/>
 										</div>
 									</div>
@@ -248,7 +249,6 @@
 										bind:value={webConfig.KAGI_SEARCH_API_KEY}
 									/>
 								</div>
-								.
 							</div>
 						{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
 							<div class="mb-2.5 flex w-full flex-col">

+ 2 - 12
src/lib/components/admin/Users/Groups.svelte

@@ -16,6 +16,7 @@
 	import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
 	import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
 	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
+	import Search from '$lib/components/icons/Search.svelte';
 	import User from '$lib/components/icons/User.svelte';
 	import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
 	import GroupModal from './Groups/EditGroupModal.svelte';
@@ -159,18 +160,7 @@
 			<div class=" flex w-full space-x-2">
 				<div class="flex flex-1">
 					<div class=" self-center ml-1 mr-3">
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
-								clip-rule="evenodd"
-							/>
-						</svg>
+						<Search />
 					</div>
 					<input
 						class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"

+ 4 - 33
src/lib/components/admin/Users/Groups/AddGroupModal.svelte

@@ -3,8 +3,10 @@
 	import { getContext, onMount } from 'svelte';
 	const i18n = getContext('i18n');
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import Textarea from '$lib/components/common/Textarea.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 	export let onSubmit: Function = () => {};
 	export let show = false;
 
@@ -45,16 +47,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -111,29 +104,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 4 - 33
src/lib/components/admin/Users/Groups/EditGroupModal.svelte

@@ -3,6 +3,7 @@
 	import { getContext, onMount } from 'svelte';
 	const i18n = getContext('i18n');
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import Display from './Display.svelte';
 	import Permissions from './Permissions.svelte';
@@ -10,6 +11,7 @@
 	import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
 	import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
 	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	export let onSubmit: Function = () => {};
 	export let onDelete: Function = () => {};
@@ -124,16 +126,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -305,29 +298,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 2 - 12
src/lib/components/admin/Users/Groups/Users.svelte

@@ -7,6 +7,7 @@
 	import { WEBUI_BASE_URL } from '$lib/constants';
 	import Checkbox from '$lib/components/common/Checkbox.svelte';
 	import Badge from '$lib/components/common/Badge.svelte';
+	import Search from '$lib/components/icons/Search.svelte';
 
 	export let users = [];
 	export let userIds = [];
@@ -50,18 +51,7 @@
 	<div class="flex w-full">
 		<div class="flex flex-1">
 			<div class=" self-center mr-3">
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-4 h-4"
-				>
-					<path
-						fill-rule="evenodd"
-						d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
-						clip-rule="evenodd"
-					/>
-				</svg>
+				<Search />
 			</div>
 			<input
 				class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"

+ 1 - 1
src/lib/components/admin/Users/UserList.svelte

@@ -151,7 +151,7 @@
 
 {#if users === null || total === null}
 	<div class="my-10">
-		<Spinner />
+		<Spinner className="size-5" />
 	</div>
 {:else}
 	<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">

+ 4 - 33
src/lib/components/admin/Users/UserList/AddUserModal.svelte

@@ -6,8 +6,10 @@
 
 	import { WEBUI_BASE_URL } from '$lib/constants';
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Modal from '$lib/components/common/Modal.svelte';
 	import { generateInitialsImage } from '$lib/utils';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
@@ -132,16 +134,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -293,29 +286,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

+ 2 - 10
src/lib/components/admin/Users/UserList/EditUserModal.svelte

@@ -8,6 +8,7 @@
 
 	import Modal from '$lib/components/common/Modal.svelte';
 	import localizedFormat from 'dayjs/plugin/localizedFormat';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
@@ -54,16 +55,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 

+ 46 - 34
src/lib/components/channel/MessageInput.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { v4 as uuidv4 } from 'uuid';
+	import heic2any from 'heic2any';
 
 	import { tick, getContext, onMount, onDestroy } from 'svelte';
 
@@ -78,7 +79,7 @@
 	};
 
 	const inputFilesHandler = async (inputFiles) => {
-		inputFiles.forEach((file) => {
+		inputFiles.forEach(async (file) => {
 			console.info('Processing file:', {
 				name: file.name,
 				type: file.type,
@@ -102,44 +103,51 @@
 				return;
 			}
 
-			if (
-				['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
-			) {
-				let reader = new FileReader();
+			if (file['type'].startsWith('image/')) {
+				const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
+					// Quick shortcut so we don’t do unnecessary work.
+					const settingsCompression = settings?.imageCompression ?? false;
+					const configWidth = config?.file?.image_compression?.width ?? null;
+					const configHeight = config?.file?.image_compression?.height ?? null;
 
-				reader.onload = async (event) => {
-					let imageUrl = event.target.result;
+					// If neither settings nor config wants compression, return original URL.
+					if (!settingsCompression && !configWidth && !configHeight) {
+						return imageUrl;
+					}
 
-					if (
-						($settings?.imageCompression ?? false) ||
-						($config?.file?.image_compression?.width ?? null) ||
-						($config?.file?.image_compression?.height ?? null)
-					) {
-						let width = null;
-						let height = null;
-
-						if ($settings?.imageCompression ?? false) {
-							width = $settings?.imageCompressionSize?.width ?? null;
-							height = $settings?.imageCompressionSize?.height ?? null;
-						}
+					// Default to null (no compression unless set)
+					let width = null;
+					let height = null;
 
-						if (
-							($config?.file?.image_compression?.width ?? null) ||
-							($config?.file?.image_compression?.height ?? null)
-						) {
-							if (width > ($config?.file?.image_compression?.width ?? null)) {
-								width = $config?.file?.image_compression?.width ?? null;
-							}
-							if (height > ($config?.file?.image_compression?.height ?? null)) {
-								height = $config?.file?.image_compression?.height ?? null;
-							}
-						}
+					// If user/settings want compression, pick their preferred size.
+					if (settingsCompression) {
+						width = settings?.imageCompressionSize?.width ?? null;
+						height = settings?.imageCompressionSize?.height ?? null;
+					}
 
-						if (width || height) {
-							imageUrl = await compressImage(imageUrl, width, height);
-						}
+					// Apply config limits as an upper bound if any
+					if (configWidth && (width === null || width > configWidth)) {
+						width = configWidth;
+					}
+					if (configHeight && (height === null || height > configHeight)) {
+						height = configHeight;
 					}
 
+					// Do the compression if required
+					if (width || height) {
+						return await compressImage(imageUrl, width, height);
+					}
+					return imageUrl;
+				};
+
+				let reader = new FileReader();
+
+				reader.onload = async (event) => {
+					let imageUrl = event.target.result;
+
+					// Compress the image if settings or config require it
+					imageUrl = await compressImageHandler(imageUrl, $settings, $config);
+
 					files = [
 						...files,
 						{
@@ -149,7 +157,11 @@
 					];
 				};
 
-				reader.readAsDataURL(file);
+				reader.readAsDataURL(
+					file['type'] === 'image/heic'
+						? await heic2any({ blob: file, toType: 'image/jpeg' })
+						: file
+				);
 			} else {
 				uploadFileHandler(file);
 			}

+ 11 - 10
src/lib/components/chat/Chat.svelte

@@ -55,10 +55,7 @@
 
 	import { generateChatCompletion } from '$lib/apis/ollama';
 	import {
-		addTagById,
 		createNewChat,
-		deleteTagById,
-		deleteTagsById,
 		getAllTags,
 		getChatById,
 		getChatList,
@@ -153,10 +150,10 @@
 			webSearchEnabled = false;
 			imageGenerationEnabled = false;
 
-			if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
+			if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
 				try {
 					const input = JSON.parse(
-						localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
+						sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
 					);
 
 					if (!$temporaryChatEnabled) {
@@ -446,7 +443,7 @@
 			}
 		});
 
-		if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
+		if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
 			prompt = '';
 			files = [];
 			selectedToolIds = [];
@@ -457,7 +454,7 @@
 
 			try {
 				const input = JSON.parse(
-					localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
+					sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
 				);
 
 				if (!$temporaryChatEnabled) {
@@ -708,6 +705,10 @@
 	//////////////////////////
 
 	const initNewChat = async () => {
+		if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
+			await temporaryChatEnabled.set(true);
+		}
+
 		const availableModels = $models
 			.filter((m) => !(m?.info?.meta?.hidden ?? false))
 			.map((m) => m.id);
@@ -2120,12 +2121,12 @@
 									onChange={(input) => {
 										if (!$temporaryChatEnabled) {
 											if (input.prompt !== null) {
-												localStorage.setItem(
+												sessionStorage.setItem(
 													`chat-input${$chatId ? `-${$chatId}` : ''}`,
 													JSON.stringify(input)
 												);
 											} else {
-												localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
+												sessionStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
 											}
 										}
 									}}
@@ -2227,7 +2228,7 @@
 	{:else if loading}
 		<div class=" flex items-center justify-center h-full w-full">
 			<div class="m-auto">
-				<Spinner />
+				<Spinner className="size-5" />
 			</div>
 		</div>
 	{/if}

+ 7 - 3
src/lib/components/chat/ChatPlaceholder.svelte

@@ -46,7 +46,9 @@
 					>
 						<Tooltip
 							content={marked.parse(
-								sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
+								sanitizeResponseContent(
+									models[selectedModelIdx]?.info?.meta?.description ?? ''
+								).replaceAll('\n', '<br>')
 							)}
 							placement="right"
 						>
@@ -68,7 +70,7 @@
 
 		{#if $temporaryChatEnabled}
 			<Tooltip
-				content={$i18n.t('This chat won’t appear in history and your messages will not be saved.')}
+				content={$i18n.t("This chat won't appear in history and your messages will not be saved.")}
 				className="w-full flex justify-start mb-0.5"
 				placement="top"
 			>
@@ -96,7 +98,9 @@
 							class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
 						>
 							{@html marked.parse(
-								sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
+								sanitizeResponseContent(
+									models[selectedModelIdx]?.info?.meta?.description
+								).replaceAll('\n', '<br>')
 							)}
 						</div>
 						{#if models[selectedModelIdx]?.info?.meta?.user}

+ 4 - 2
src/lib/components/chat/Controls/Controls.svelte

@@ -9,7 +9,7 @@
 	import FileItem from '$lib/components/common/FileItem.svelte';
 	import Collapsible from '$lib/components/common/Collapsible.svelte';
 
-	import { user } from '$lib/stores';
+	import { user, settings } from '$lib/stores';
 	export let models = [];
 	export let chatFiles = [];
 	export let params = {};
@@ -74,7 +74,9 @@
 				<div class="" slot="content">
 					<textarea
 						bind:value={params.system}
-						class="w-full text-xs py-1.5 bg-transparent outline-hidden resize-none"
+						class="w-full text-xs outline-hidden resize-vertical {$settings.highContrastMode
+							? 'border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 p-2.5'
+							: 'py-1.5 bg-transparent'}"
 						rows="4"
 						placeholder={$i18n.t('Enter system prompt')}
 					/>

+ 61 - 38
src/lib/components/chat/MessageInput.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import DOMPurify from 'dompurify';
 	import { marked } from 'marked';
+	import heic2any from 'heic2any';
 
 	import { toast } from 'svelte-sonner';
 
@@ -320,7 +321,7 @@
 			return;
 		}
 
-		inputFiles.forEach((file) => {
+		inputFiles.forEach(async (file) => {
 			console.log('Processing file:', {
 				name: file.name,
 				type: file.type,
@@ -344,47 +345,54 @@
 				return;
 			}
 
-			if (
-				['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
-			) {
+			if (file['type'].startsWith('image/')) {
 				if (visionCapableModels.length === 0) {
 					toast.error($i18n.t('Selected model(s) do not support image inputs'));
 					return;
 				}
-				let reader = new FileReader();
-				reader.onload = async (event) => {
-					let imageUrl = event.target.result;
 
-					if (
-						($settings?.imageCompression ?? false) ||
-						($config?.file?.image_compression?.width ?? null) ||
-						($config?.file?.image_compression?.height ?? null)
-					) {
-						let width = null;
-						let height = null;
-
-						if ($settings?.imageCompression ?? false) {
-							width = $settings?.imageCompressionSize?.width ?? null;
-							height = $settings?.imageCompressionSize?.height ?? null;
-						}
+				const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
+					// Quick shortcut so we don’t do unnecessary work.
+					const settingsCompression = settings?.imageCompression ?? false;
+					const configWidth = config?.file?.image_compression?.width ?? null;
+					const configHeight = config?.file?.image_compression?.height ?? null;
 
-						if (
-							($config?.file?.image_compression?.width ?? null) ||
-							($config?.file?.image_compression?.height ?? null)
-						) {
-							if (width > ($config?.file?.image_compression?.width ?? null)) {
-								width = $config?.file?.image_compression?.width ?? null;
-							}
-							if (height > ($config?.file?.image_compression?.height ?? null)) {
-								height = $config?.file?.image_compression?.height ?? null;
-							}
-						}
+					// If neither settings nor config wants compression, return original URL.
+					if (!settingsCompression && !configWidth && !configHeight) {
+						return imageUrl;
+					}
 
-						if (width || height) {
-							imageUrl = await compressImage(imageUrl, width, height);
-						}
+					// Default to null (no compression unless set)
+					let width = null;
+					let height = null;
+
+					// If user/settings want compression, pick their preferred size.
+					if (settingsCompression) {
+						width = settings?.imageCompressionSize?.width ?? null;
+						height = settings?.imageCompressionSize?.height ?? null;
 					}
 
+					// Apply config limits as an upper bound if any
+					if (configWidth && (width === null || width > configWidth)) {
+						width = configWidth;
+					}
+					if (configHeight && (height === null || height > configHeight)) {
+						height = configHeight;
+					}
+
+					// Do the compression if required
+					if (width || height) {
+						return await compressImage(imageUrl, width, height);
+					}
+					return imageUrl;
+				};
+
+				let reader = new FileReader();
+				reader.onload = async (event) => {
+					let imageUrl = event.target.result;
+
+					imageUrl = await compressImageHandler(imageUrl, $settings, $config);
+
 					files = [
 						...files,
 						{
@@ -393,7 +401,11 @@
 						}
 					];
 				};
-				reader.readAsDataURL(file);
+				reader.readAsDataURL(
+					file['type'] === 'image/heic'
+						? await heic2any({ blob: file, toType: 'image/jpeg' })
+						: file
+				);
 			} else {
 				uploadFileHandler(file);
 			}
@@ -659,7 +671,7 @@
 													<div class="relative flex items-center">
 														<Image
 															src={file.url}
-															alt="input"
+															alt=""
 															imageClassName=" size-14 rounded-xl object-cover"
 														/>
 														{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
@@ -677,6 +689,7 @@
 																	xmlns="http://www.w3.org/2000/svg"
 																	viewBox="0 0 24 24"
 																	fill="currentColor"
+																	aria-hidden="true"
 																	class="size-4 fill-yellow-300"
 																>
 																	<path
@@ -690,8 +703,12 @@
 													</div>
 													<div class=" absolute -top-1 -right-1">
 														<button
-															class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
+															class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
+															false)
+																? ''
+																: 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
 															type="button"
+															aria-label={$i18n.t('Remove file')}
 															on:click={() => {
 																files.splice(fileIdx, 1);
 																files = files;
@@ -701,6 +718,7 @@
 																xmlns="http://www.w3.org/2000/svg"
 																viewBox="0 0 20 20"
 																fill="currentColor"
+																aria-hidden="true"
 																class="size-4"
 															>
 																<path
@@ -1253,11 +1271,12 @@
 											<button
 												class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
 												type="button"
-												aria-label="More"
+												aria-label={$i18n.t('More Available Tools')}
 											>
 												<svg
 													xmlns="http://www.w3.org/2000/svg"
 													viewBox="0 0 20 20"
+													aria-hidden="true"
 													fill="currentColor"
 													class="size-5"
 												>
@@ -1379,6 +1398,10 @@
 												{#if showCodeInterpreterButton}
 													<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
 														<button
+															aria-label={codeInterpreterEnabled
+																? $i18n.t('Disable Code Interpreter')
+																: $i18n.t('Enable Code Interpreter')}
+															aria-pressed={codeInterpreterEnabled}
 															on:click|preventDefault={() =>
 																(codeInterpreterEnabled = !codeInterpreterEnabled)}
 															type="button"
@@ -1530,7 +1553,7 @@
 																);
 															}
 														}}
-														aria-label="Call"
+														aria-label={$i18n.t('Voice mode')}
 													>
 														<Headphone className="size-5" />
 													</button>

+ 1 - 0
src/lib/components/chat/MessageInput/CallOverlay.svelte

@@ -854,6 +854,7 @@
 				</button>
 			{:else}
 				<div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
+					<!-- svelte-ignore a11y-media-has-caption -->
 					<video
 						id="camera-feed"
 						autoplay

+ 34 - 8
src/lib/components/chat/MessageInput/Commands/Knowledge.svelte

@@ -6,7 +6,7 @@
 	import relativeTime from 'dayjs/plugin/relativeTime';
 	dayjs.extend(relativeTime);
 
-	import { createEventDispatcher, tick, getContext, onMount } from 'svelte';
+	import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
 	import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
 	import { knowledge } from '$lib/stores';
 
@@ -42,6 +42,24 @@
 		selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
 	};
 
+	let container;
+	let adjustHeightDebounce;
+
+	const adjustHeight = () => {
+		if (container) {
+			if (adjustHeightDebounce) {
+				clearTimeout(adjustHeightDebounce);
+			}
+
+			adjustHeightDebounce = setTimeout(() => {
+				if (!container) return;
+
+				// Ensure the container is visible before adjusting height
+				const rect = container.getBoundingClientRect();
+				container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
+			}, 100);
+		}
+	};
 	const confirmSelect = async (item) => {
 		dispatch('select', item);
 
@@ -75,7 +93,18 @@
 		await tick();
 	};
 
+	const decodeString = (str: string) => {
+		try {
+			return decodeURIComponent(str);
+		} catch (e) {
+			return str;
+		}
+	};
+
 	onMount(() => {
+		window.addEventListener('resize', adjustHeight);
+		adjustHeight();
+
 		let legacy_documents = $knowledge
 			.filter((item) => item?.meta?.document)
 			.map((item) => ({
@@ -155,13 +184,9 @@
 		});
 	});
 
-	const decodeString = (str: string) => {
-		try {
-			return decodeURIComponent(str);
-		} catch (e) {
-			return str;
-		}
-	};
+	onDestroy(() => {
+		window.removeEventListener('resize', adjustHeight);
+	});
 </script>
 
 {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
@@ -174,6 +199,7 @@
 				<div
 					class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60"
 					id="command-options-container"
+					bind:this={container}
 				>
 					{#each filteredItems as item, idx}
 						<button

+ 28 - 1
src/lib/components/chat/MessageInput/Commands/Models.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import Fuse from 'fuse.js';
 
-	import { createEventDispatcher, onMount } from 'svelte';
+	import { createEventDispatcher, onDestroy, onMount } from 'svelte';
 	import { tick, getContext } from 'svelte';
 
 	import { models } from '$lib/stores';
@@ -51,18 +51,44 @@
 		selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
 	};
 
+	let container;
+	let adjustHeightDebounce;
+
+	const adjustHeight = () => {
+		if (container) {
+			if (adjustHeightDebounce) {
+				clearTimeout(adjustHeightDebounce);
+			}
+
+			adjustHeightDebounce = setTimeout(() => {
+				if (!container) return;
+
+				// Ensure the container is visible before adjusting height
+				const rect = container.getBoundingClientRect();
+				container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
+			}, 100);
+		}
+	};
+
 	const confirmSelect = async (model) => {
 		command = '';
 		dispatch('select', model);
 	};
 
 	onMount(async () => {
+		window.addEventListener('resize', adjustHeight);
+		adjustHeight();
+
 		await tick();
 		const chatInputElement = document.getElementById('chat-input');
 		await tick();
 		chatInputElement?.focus();
 		await tick();
 	});
+
+	onDestroy(() => {
+		window.removeEventListener('resize', adjustHeight);
+	});
 </script>
 
 {#if filteredItems.length > 0}
@@ -75,6 +101,7 @@
 				<div
 					class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60"
 					id="command-options-container"
+					bind:this={container}
 				>
 					{#each filteredItems as model, modelIdx}
 						<button

+ 30 - 1
src/lib/components/chat/MessageInput/Commands/Prompts.svelte

@@ -9,7 +9,7 @@
 		getUserTimezone,
 		getWeekday
 	} from '$lib/utils';
-	import { tick, getContext } from 'svelte';
+	import { tick, getContext, onMount, onDestroy } from 'svelte';
 	import { toast } from 'svelte-sonner';
 
 	const i18n = getContext('i18n');
@@ -38,6 +38,25 @@
 		selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1);
 	};
 
+	let container;
+	let adjustHeightDebounce;
+
+	const adjustHeight = () => {
+		if (container) {
+			if (adjustHeightDebounce) {
+				clearTimeout(adjustHeightDebounce);
+			}
+
+			adjustHeightDebounce = setTimeout(() => {
+				if (!container) return;
+
+				// Ensure the container is visible before adjusting height
+				const rect = container.getBoundingClientRect();
+				container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
+			}, 100);
+		}
+	};
+
 	const confirmPrompt = async (command) => {
 		let text = command.content;
 
@@ -172,6 +191,15 @@
 			}
 		}
 	};
+
+	onMount(() => {
+		window.addEventListener('resize', adjustHeight);
+		adjustHeight();
+	});
+
+	onDestroy(() => {
+		window.removeEventListener('resize', adjustHeight);
+	});
 </script>
 
 {#if filteredPrompts.length > 0}
@@ -184,6 +212,7 @@
 				<div
 					class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60"
 					id="command-options-container"
+					bind:this={container}
 				>
 					{#each filteredPrompts as prompt, promptIdx}
 						<button

+ 2 - 10
src/lib/components/chat/MessageInput/VoiceRecording.svelte

@@ -5,6 +5,7 @@
 	import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
 
 	import { transcribeAudio } from '$lib/apis/audio';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	import dayjs from 'dayjs';
 	import LocalizedFormat from 'dayjs/plugin/localizedFormat';
@@ -406,16 +407,7 @@
 				onCancel();
 			}}
 		>
-			<svg
-				xmlns="http://www.w3.org/2000/svg"
-				fill="none"
-				viewBox="0 0 24 24"
-				stroke-width="3"
-				stroke="currentColor"
-				class="size-4"
-			>
-				<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-			</svg>
+			<XMark className={'size-4'} />
 		</button>
 	</div>
 

+ 1 - 0
src/lib/components/chat/Messages.svelte

@@ -438,6 +438,7 @@
 						<Message
 							{chatId}
 							bind:history
+							{selectedModels}
 							messageId={message.id}
 							idx={messageIdx}
 							{user}

+ 0 - 1
src/lib/components/chat/Messages/Citations.svelte

@@ -43,7 +43,6 @@
 	}
 
 	$: {
-		console.log('sources', sources);
 		citations = sources.reduce((acc, source) => {
 			if (Object.keys(source).length === 0) {
 				return acc;

+ 3 - 10
src/lib/components/chat/Messages/CitationsModal.svelte

@@ -4,6 +4,8 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import { WEBUI_API_BASE_URL } from '$lib/constants';
 
+	import XMark from '$lib/components/icons/XMark.svelte';
+
 	const i18n = getContext('i18n');
 
 	export let show = false;
@@ -67,16 +69,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 

+ 2 - 10
src/lib/components/chat/Messages/CodeExecutionModal.svelte

@@ -4,6 +4,7 @@
 	import Modal from '$lib/components/common/Modal.svelte';
 	import Spinner from '$lib/components/common/Spinner.svelte';
 	import Badge from '$lib/components/common/Badge.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 	const i18n = getContext('i18n');
 
 	export let show = false;
@@ -49,16 +50,7 @@
 					codeExecution = null;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 

+ 11 - 6
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -18,6 +18,8 @@
 	export let id;
 	export let content;
 	export let history;
+	export let selectedModels = [];
+
 	export let model = null;
 	export let sources = null;
 
@@ -25,11 +27,10 @@
 	export let preview = false;
 	export let floatingButtons = true;
 
-	export let onSave = () => {};
-	export let onSourceClick = () => {};
-	export let onTaskClick = () => {};
-
-	export let onAddMessages = () => {};
+	export let onSave = (e) => {};
+	export let onSourceClick = (e) => {};
+	export let onTaskClick = (e) => {};
+	export let onAddMessages = (e) => {};
 
 	let contentContainerElement;
 
@@ -192,7 +193,11 @@
 	<FloatingButtons
 		bind:this={floatingButtonsElement}
 		{id}
-		model={model?.id}
+		model={(selectedModels ?? []).includes(model?.id)
+			? model?.id
+			: (selectedModels ?? []).length > 0
+				? selectedModels.at(0)
+				: model?.id}
 		messages={createMessagesList(history, id)}
 		onAdd={({ modelId, parentId, messages }) => {
 			console.log(modelId, parentId, messages);

+ 2 - 1
src/lib/components/chat/Messages/Markdown.svelte

@@ -27,7 +27,8 @@
 	let tokens = [];
 
 	const options = {
-		throwOnError: false
+		throwOnError: false,
+		breaks: true
 	};
 
 	marked.use(markedKatexExtension(options));

+ 3 - 0
src/lib/components/chat/Messages/Message.svelte

@@ -13,6 +13,7 @@
 	import UserMessage from './UserMessage.svelte';
 
 	export let chatId;
+	export let selectedModels = [];
 	export let idx = 0;
 
 	export let history;
@@ -70,6 +71,7 @@
 				{chatId}
 				{history}
 				{messageId}
+				{selectedModels}
 				isLastMessage={messageId === history.currentId}
 				siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
 				{gotoMessage}
@@ -92,6 +94,7 @@
 				bind:history
 				{chatId}
 				{messageId}
+				{selectedModels}
 				isLastMessage={messageId === history?.currentId}
 				{updateChat}
 				{editMessage}

+ 2 - 0
src/lib/components/chat/Messages/MultiResponseMessages.svelte

@@ -23,6 +23,7 @@
 	export let chatId;
 	export let history;
 	export let messageId;
+	export let selectedModels = [];
 
 	export let isLastMessage;
 	export let readOnly = false;
@@ -252,6 +253,7 @@
 									{chatId}
 									{history}
 									messageId={_messageId}
+									{selectedModels}
 									isLastMessage={true}
 									siblings={groupedMessageIds[modelIdx].messageIds}
 									gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)}

+ 2 - 10
src/lib/components/chat/Messages/RateComment.svelte

@@ -4,6 +4,7 @@
 	import { createEventDispatcher, onMount, getContext } from 'svelte';
 	import { config, models } from '$lib/stores';
 	import Tags from '$lib/components/common/Tags.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -123,16 +124,7 @@
 				show = false;
 			}}
 		>
-			<svg
-				xmlns="http://www.w3.org/2000/svg"
-				fill="none"
-				viewBox="0 0 24 24"
-				stroke-width="1.5"
-				stroke="currentColor"
-				class="size-4"
-			>
-				<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-			</svg>
+			<XMark className={'size-4'} />
 		</button>
 	</div>
 

+ 4 - 2
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -106,6 +106,7 @@
 	export let chatId = '';
 	export let history;
 	export let messageId;
+	export let selectedModels = [];
 
 	let message: MessageType = JSON.parse(JSON.stringify(history.messages[messageId]));
 	$: if (history.messages) {
@@ -601,7 +602,7 @@
 		id="message-{message.id}"
 		dir={$settings.chatDirection}
 	>
-		<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex `}>
+		<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
 			<ProfileImage
 				src={model?.info?.meta?.profile_image_url ??
 					($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
@@ -609,7 +610,7 @@
 			/>
 		</div>
 
-		<div class="flex-auto w-0 pl-1 relative -translate-y-0.5">
+		<div class="flex-auto w-0 pl-1 relative">
 			<Name>
 				<Tooltip content={model?.name ?? message.model} placement="top-start">
 					<span class="line-clamp-1 text-black dark:text-white">
@@ -795,6 +796,7 @@
 									<ContentRenderer
 										id={message.id}
 										{history}
+										{selectedModels}
 										content={message.content}
 										sources={message.sources}
 										floatingButtons={message?.done && !readOnly}

+ 2 - 2
src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
 	import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
-	import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
+	import Search from '$lib/components/icons/Search.svelte';
 	import Collapsible from '$lib/components/common/Collapsible.svelte';
 
 	export let status = { urls: [], query: '' };
@@ -31,7 +31,7 @@
 				class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline"
 			>
 				<div class="flex gap-2 items-center">
-					<MagnifyingGlass />
+					<Search />
 
 					<div class=" line-clamp-1">
 						{status.query}

+ 24 - 39
src/lib/components/chat/Messages/Skeleton.svelte

@@ -2,44 +2,29 @@
 	export let size = 'md';
 </script>
 
-<div class="w-full mt-2 mb-2">
-	<div class="animate-pulse flex w-full">
-		<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
-			<div
-				class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14"
-			/>
+<span class="relative flex {size === 'md' ? 'size-3 my-2' : 'size-2 my-1'} mx-1">
+	<span
+		class="absolute inline-flex h-full w-full animate-pulse rounded-full bg-gray-700 dark:bg-gray-200 opacity-75"
+	></span>
+	<span
+		class="relative inline-flex {size === 'md'
+			? 'size-3'
+			: 'size-2'} rounded-full bg-black dark:bg-white animate-size"
+	></span>
+</span>
 
-			<div class="grid grid-cols-3 gap-4">
-				<div
-					class="{size === 'md'
-						? 'h-2'
-						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
-				/>
-				<div
-					class="{size === 'md'
-						? 'h-2'
-						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
-				/>
-			</div>
-			<div class="grid grid-cols-4 gap-4">
-				<div
-					class="{size === 'md'
-						? 'h-2'
-						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
-				/>
-				<div
-					class="{size === 'md'
-						? 'h-2'
-						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
-				/>
-				<div
-					class="{size === 'md'
-						? 'h-2'
-						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1 mr-4"
-				/>
-			</div>
+<style>
+	@keyframes size {
+		0%,
+		100% {
+			transform: scale(1);
+		}
+		50% {
+			transform: scale(1.25);
+		}
+	}
 
-			<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm" />
-		</div>
-	</div>
-</div>
+	.animate-size {
+		animation: size 1.5s ease-in-out infinite;
+	}
+</style>

+ 1 - 1
src/lib/components/chat/Messages/UserMessage.svelte

@@ -113,7 +113,7 @@
 	id="message-{message.id}"
 >
 	{#if !($settings?.chatBubble ?? true)}
-		<div class={`shrink-0 ltr:mr-3 rtl:ml-3`}>
+		<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
 			<ProfileImage
 				src={message.user
 					? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??

+ 1 - 1
src/lib/components/chat/ModelSelector.svelte

@@ -49,7 +49,7 @@
 	{#each selectedModels as selectedModel, selectedModelIdx}
 		<div class="flex w-full max-w-fit">
 			<div class="overflow-hidden w-full">
-				<div class="mr-1 max-w-full">
+				<div class="max-w-full {($settings?.highContrastMode ?? false) ? 'm-1' : 'mr-1'}">
 					<Selector
 						id={`${selectedModelIdx}`}
 						placeholder={$i18n.t('Select a model')}

+ 11 - 28
src/lib/components/chat/ModelSelector/Selector.svelte

@@ -7,6 +7,7 @@
 	import relativeTime from 'dayjs/plugin/relativeTime';
 	dayjs.extend(relativeTime);
 
+	import Spinner from '$lib/components/common/Spinner.svelte';
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
 	import { goto } from '$app/navigation';
@@ -345,12 +346,17 @@
 	closeFocus={false}
 >
 	<DropdownMenu.Trigger
-		class="relative w-full font-primary"
+		class="relative w-full font-primary {($settings?.highContrastMode ?? false)
+			? ''
+			: 'outline-hidden focus:outline-hidden'}"
 		aria-label={placeholder}
 		id="model-selector-{id}-button"
 	>
-		<button
-			class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden"
+		<div
+			class="flex w-full text-left px-0.5 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
+			false)
+				? 'dark:placeholder-gray-100 placeholder-gray-800'
+				: 'placeholder-gray-400'}"
 			on:mouseenter={async () => {
 				models.set(
 					await getModels(
@@ -359,7 +365,6 @@
 					)
 				);
 			}}
-			type="button"
 		>
 			{#if selectedModel}
 				{selectedModel.label}
@@ -367,7 +372,7 @@
 				{placeholder}
 			{/if}
 			<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
-		</button>
+		</div>
 	</DropdownMenu.Trigger>
 
 	<DropdownMenu.Content
@@ -550,29 +555,7 @@
 					>
 						<div class="flex">
 							<div class="-ml-2 mr-2.5 translate-y-0.5">
-								<svg
-									class="size-4"
-									viewBox="0 0 24 24"
-									fill="currentColor"
-									xmlns="http://www.w3.org/2000/svg"
-									><style>
-										.spinner_ajPY {
-											transform-origin: center;
-											animation: spinner_AtaB 0.75s infinite linear;
-										}
-										@keyframes spinner_AtaB {
-											100% {
-												transform: rotate(360deg);
-											}
-										}
-									</style><path
-										d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-										opacity=".25"
-									/><path
-										d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-										class="spinner_ajPY"
-									/></svg
-								>
+								<Spinner />
 							</div>
 
 							<div class="flex flex-col self-start">

+ 4 - 4
src/lib/components/chat/Navbar.svelte

@@ -173,19 +173,19 @@
 								}
 							}}
 						>
-							<button
+							<div
 								class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
-								aria-label="User Menu"
 							>
 								<div class=" self-center">
+									<span class="sr-only">{$i18n.t('User menu')}</span>
 									<img
 										src={$user?.profile_image_url}
 										class="size-6 object-cover rounded-full"
-										alt="User profile"
+										alt=""
 										draggable="false"
 									/>
 								</div>
-							</button>
+							</div>
 						</UserMenu>
 					{/if}
 				</div>

+ 11 - 5
src/lib/components/chat/Placeholder.svelte

@@ -118,6 +118,8 @@
 								placement="top"
 							>
 								<button
+									aria-hidden={models.length <= 1}
+									aria-label={$i18n.t('Get information on {{name}} in the UI', { name: models[modelIdx]?.name})}
 									on:click={() => {
 										selectedModelIdx = modelIdx;
 									}}
@@ -129,7 +131,7 @@
 												? `/doge.png`
 												: `${WEBUI_BASE_URL}/static/favicon.png`)}
 										class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
-										alt="logo"
+										aria-hidden="true"
 										draggable="false"
 									/>
 								</button>
@@ -164,7 +166,9 @@
 						<Tooltip
 							className=" w-fit"
 							content={marked.parse(
-								sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
+								sanitizeResponseContent(
+									models[selectedModelIdx]?.info?.meta?.description ?? ''
+								).replaceAll('\n', '<br>')
 							)}
 							placement="top"
 						>
@@ -172,7 +176,9 @@
 								class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
 							>
 								{@html marked.parse(
-									sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
+									sanitizeResponseContent(
+										models[selectedModelIdx]?.info?.meta?.description ?? ''
+									).replaceAll('\n', '<br>')
 								)}
 							</div>
 						</Tooltip>
@@ -218,9 +224,9 @@
 					onChange={(input) => {
 						if (!$temporaryChatEnabled) {
 							if (input.prompt !== null) {
-								localStorage.setItem(`chat-input`, JSON.stringify(input));
+								sessionStorage.setItem(`chat-input`, JSON.stringify(input));
 							} else {
-								localStorage.removeItem(`chat-input`);
+								sessionStorage.removeItem(`chat-input`);
 							}
 						}
 					}}

+ 13 - 9
src/lib/components/chat/Settings/About.svelte

@@ -38,7 +38,9 @@
 			return '';
 		});
 
-		checkForVersionUpdates();
+		if (!$config?.offline_mode) {
+			checkForVersionUpdates();
+		}
 	});
 </script>
 
@@ -80,14 +82,16 @@
 					</button>
 				</div>
 
-				<button
-					class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
-					on:click={() => {
-						checkForVersionUpdates();
-					}}
-				>
-					{$i18n.t('Check for updates')}
-				</button>
+				{#if $config?.offline_mode}
+					<button
+						class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
+						on:click={() => {
+							checkForVersionUpdates();
+						}}
+					>
+						{$i18n.t('Check for updates')}
+					</button>
+				{/if}
 			</div>
 		</div>
 

+ 14 - 5
src/lib/components/chat/Settings/Chats.svelte

@@ -6,11 +6,10 @@
 
 	import {
 		archiveAllChats,
-		createNewChat,
 		deleteAllChats,
 		getAllChats,
-		getAllUserChats,
-		getChatList
+		getChatList,
+		importChat
 	} from '$lib/apis/chats';
 	import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
 	import { onMount, getContext } from 'svelte';
@@ -58,9 +57,18 @@
 			console.log(chat);
 
 			if (chat.chat) {
-				await createNewChat(localStorage.token, chat.chat);
+				await importChat(
+					localStorage.token,
+					chat.chat,
+					chat.meta ?? {},
+					false,
+					null,
+					chat?.created_at ?? null,
+					chat?.updated_at ?? null
+				);
 			} else {
-				await createNewChat(localStorage.token, chat);
+				// Legacy format
+				await importChat(localStorage.token, chat, {}, false, null);
 			}
 		}
 
@@ -101,6 +109,7 @@
 	const handleArchivedChatsChange = async () => {
 		currentChatPage.set(1);
 		await chats.set(await getChatList(localStorage.token, $currentChatPage));
+
 		scrollPaginationEnabled.set(true);
 	};
 </script>

+ 4 - 1
src/lib/components/chat/Settings/General.svelte

@@ -274,7 +274,10 @@
 				<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
 				<Textarea
 					bind:value={system}
-					className="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-900 outline-hidden resize-none"
+					className={'w-full text-sm outline-hidden resize-vertical' +
+						($settings.highContrastMode
+							? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
+							: ' bg-white dark:text-gray-300 dark:bg-gray-900')}
 					rows="4"
 					placeholder={$i18n.t('Enter system prompt here')}
 				/>

+ 4 - 33
src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte

@@ -4,6 +4,8 @@
 	import Modal from '$lib/components/common/Modal.svelte';
 	import { addNewMemory, updateMemoryById } from '$lib/apis/memories';
 	import { toast } from 'svelte-sonner';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
 
 	const dispatch = createEventDispatcher();
 
@@ -46,16 +48,7 @@
 					show = false;
 				}}
 			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 20 20"
-					fill="currentColor"
-					class="w-5 h-5"
-				>
-					<path
-						d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-					/>
-				</svg>
+				<XMark className={'size-5'} />
 			</button>
 		</div>
 
@@ -93,29 +86,7 @@
 
 							{#if loading}
 								<div class="ml-2 self-center">
-									<svg
-										class=" w-4 h-4"
-										viewBox="0 0 24 24"
-										fill="currentColor"
-										xmlns="http://www.w3.org/2000/svg"
-										><style>
-											.spinner_ajPY {
-												transform-origin: center;
-												animation: spinner_AtaB 0.75s infinite linear;
-											}
-											@keyframes spinner_AtaB {
-												100% {
-													transform: rotate(360deg);
-												}
-											}
-										</style><path
-											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-											opacity=".25"
-										/><path
-											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-											class="spinner_ajPY"
-										/></svg
-									>
+									<Spinner />
 								</div>
 							{/if}
 						</button>

Некоторые файлы не были показаны из-за большого количества измененных файлов