diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d50d38d848..ad311a371a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,6 +10,12 @@ This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR. + + **Before submitting, make sure you've checked the following:** - [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.** diff --git a/LICENSE b/LICENSE index faa0129c65..99f39e7fef 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,5 @@ +Open WebUI License + Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek] All rights reserved. @@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met: contributors may be used to endorse or promote products derived from this software without specific prior written permission. -4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. +4. Notwithstanding any other provision of this License, and as a material + condition of the rights granted herein, licensees are strictly prohibited + from altering, removing, obscuring, or replacing any "Open WebUI" + branding, including but not limited to the name, logo, or any visual, + textual, or symbolic identifiers that distinguish the software and its + interfaces, in any deployment or distribution, except in the following + circumstances: (i) deployments or distributions where the total number + of end users (defined as individual natural persons with direct access + to the application) does not exceed fifty (50) within any rolling + thirty (30) day period; (ii) the licensee has obtained specific prior + written permission from the copyright holder; or (iii) where the + licensee has obtained a duly executed enterprise license expressly + permitting such modification. For all other cases, any removal or + alteration of the "Open WebUI" branding shall constitute a material + breach of license. -5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. +Materials governed by prior licenses retain those original license +terms, as specified in LICENSE_HISTORY. -6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. +By contributing to this project, you agree to the project's Contributor +License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT). THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index fe25dda1c1..a68720a7c0 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -35,7 +36,7 @@ from open_webui.env import ( WEBUI_NAME, log, ) -from open_webui.internal.db import Base, get_db +from open_webui.internal.db import Base, get_db, get_async_db from open_webui.utils.redis import get_redis_connection @@ -90,6 +91,7 @@ def load_json_config(): def save_to_db(data): + """Sync save — used ONLY at startup/import time.""" with get_db() as db: existing_config = db.query(Config).first() if not existing_config: @@ -102,12 +104,39 @@ def save_to_db(data): db.commit() +async def async_save_to_db(data): + """Async save — used for ALL runtime config persistence.""" + from sqlalchemy import select + + async with get_async_db() as db: + result = await db.execute(select(Config).limit(1)) + existing_config = result.scalars().first() + if not existing_config: + new_config = Config(data=data, version=0) + db.add(new_config) + else: + existing_config.data = data + existing_config.updated_at = datetime.now() + db.add(existing_config) + await db.commit() + + def reset_config(): + """Sync reset — used ONLY at startup.""" with get_db() as db: db.query(Config).delete() db.commit() +async def async_reset_config(): + """Async reset — used at runtime.""" + from sqlalchemy import delete as sa_delete + + async with get_async_db() as db: + await db.execute(sa_delete(Config)) + await db.commit() + + # When initializing, check if config.json exists and migrate it to the database if os.path.exists(f'{DATA_DIR}/config.json'): data = load_json_config() @@ -144,6 +173,7 @@ PERSISTENT_CONFIG_REGISTRY = [] def save_config(config): + """Sync save — used ONLY at startup/import time.""" global CONFIG_DATA global PERSISTENT_CONFIG_REGISTRY try: @@ -159,6 +189,23 @@ def save_config(config): return True +async def async_save_config(config): + """Async save — used for ALL runtime config persistence.""" + global CONFIG_DATA + global PERSISTENT_CONFIG_REGISTRY + try: + await async_save_to_db(config) + CONFIG_DATA = config + + # Trigger updates on all registered PersistentConfig entries + for config_item in PERSISTENT_CONFIG_REGISTRY: + config_item.update() + except Exception as e: + log.exception(e) + return False + return True + + T = TypeVar('T') ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true' @@ -202,6 +249,7 @@ class PersistentConfig(Generic[T]): log.info(f'Updated {self.env_name} to new value {self.value}') def save(self): + """Sync save — used ONLY at startup/import time.""" log.info(f"Saving '{self.env_name}' to the database") path_parts = self.config_path.split('.') sub_config = CONFIG_DATA @@ -213,6 +261,19 @@ class PersistentConfig(Generic[T]): save_to_db(CONFIG_DATA) self.config_value = self.value + async def async_save(self): + """Async save — used for ALL runtime config persistence.""" + log.info(f"Saving '{self.env_name}' to the database") + path_parts = self.config_path.split('.') + sub_config = CONFIG_DATA + for key in path_parts[:-1]: + if key not in sub_config: + sub_config[key] = {} + sub_config = sub_config[key] + sub_config[path_parts[-1]] = self.value + await async_save_to_db(CONFIG_DATA) + self.config_value = self.value + class AppConfig: _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None @@ -246,12 +307,27 @@ class AppConfig: self._state[key] = value else: self._state[key].value = value - self._state[key].save() + + # At runtime (inside the event loop) persist via the async engine + # to avoid blocking the loop and contending with the async DB pool. + # At startup/import time, fall back to sync. + try: + loop = asyncio.get_running_loop() + loop.create_task(self._async_persist(key)) + except RuntimeError: + self._state[key].save() if self._redis and ENABLE_PERSISTENT_CONFIG: redis_key = f'{self._redis_key_prefix}:config:{key}' self._redis.set(redis_key, json.dumps(self._state[key].value)) + async def _async_persist(self, key): + """Persist a single config key via the async engine.""" + try: + await self._state[key].async_save() + except Exception as e: + log.error(f'Failed to async-persist config key {key}: {e}') + def __getattr__(self, key): if key not in self._state: raise AttributeError(f"Config key '{key}' not found") @@ -1149,10 +1225,16 @@ ENABLE_SIGNUP = PersistentConfig( ENABLE_LOGIN_FORM = PersistentConfig( 'ENABLE_LOGIN_FORM', - 'ui.ENABLE_LOGIN_FORM', + 'ui.enable_login_form', os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true', ) +ENABLE_PASSWORD_CHANGE_FORM = PersistentConfig( + 'ENABLE_PASSWORD_CHANGE_FORM', + 'ui.enable_password_change_form', + os.environ.get('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true', +) + ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true' DEFAULT_LOCALE = PersistentConfig( @@ -2883,6 +2965,12 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' ) +RAG_RERANKING_BATCH_SIZE = PersistentConfig( + 'RAG_RERANKING_BATCH_SIZE', + 'rag.reranking_batch_size', + int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')), +) + RAG_EXTERNAL_RERANKER_URL = PersistentConfig( 'RAG_EXTERNAL_RERANKER_URL', 'rag.external_reranker_url', diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index c0c79fdf50..ad1bdf4a20 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -91,6 +91,17 @@ class ERROR_MESSAGES(str, Enum): INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.' + AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})' + AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.' + AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}' + AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences' + + FEATURE_DISABLED = lambda name='': f'{name} is disabled' + INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}' + SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error' + REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty' + OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured" + class TASKS(str, Enum): def __str__(self) -> str: diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 8c1e8c2673..a395a424bf 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -397,7 +397,35 @@ else: except Exception: DATABASE_POOL_RECYCLE = 3600 -DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'False').lower() == 'true' +DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'True').lower() == 'true' + +# SQLite PRAGMA tuning — these defaults are optimised for WAL-mode web-server +# workloads. Each can be overridden via its environment variable. +# Set any value to an empty string to skip that PRAGMA entirely. + +# PRAGMA synchronous: NORMAL (1) is safe with WAL and avoids an fsync per +# transaction. Valid values: OFF (0), NORMAL (1), FULL (2), EXTRA (3). +DATABASE_SQLITE_PRAGMA_SYNCHRONOUS = os.environ.get('DATABASE_SQLITE_PRAGMA_SYNCHRONOUS', 'NORMAL') + +# PRAGMA busy_timeout (ms): how long a connection waits for a write lock +# before raising SQLITE_BUSY. +DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT = os.environ.get('DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT', '5000') + +# PRAGMA cache_size: negative value = KiB. -65536 ≈ 64 MB page cache. +DATABASE_SQLITE_PRAGMA_CACHE_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_CACHE_SIZE', '-65536') + +# PRAGMA temp_store: MEMORY (2) keeps temp tables and indices in RAM. +# Valid values: DEFAULT (0), FILE (1), MEMORY (2). +DATABASE_SQLITE_PRAGMA_TEMP_STORE = os.environ.get('DATABASE_SQLITE_PRAGMA_TEMP_STORE', 'MEMORY') + +# PRAGMA mmap_size (bytes): memory-mapped I/O size. 268435456 ≈ 256 MB. +# Set to 0 to disable mmap. +DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', '268435456') + +# PRAGMA journal_size_limit (bytes): caps the WAL file size after checkpoint. +# Without this the WAL grows unbounded during write bursts and is never +# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default). +DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.environ.get('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864') DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None) if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None: @@ -539,6 +567,11 @@ PASSWORD_VALIDATION_HINT = os.environ.get('PASSWORD_VALIDATION_HINT', '') BYPASS_MODEL_ACCESS_CONTROL = os.environ.get('BYPASS_MODEL_ACCESS_CONTROL', 'False').lower() == 'true' +# When enabled, skips pydub-based preprocessing (format conversion, compression, +# and chunked splitting) before sending files to processing engines. Useful when +# the upstream provider handles these steps or when ffmpeg is unavailable. +BYPASS_PYDUB_PREPROCESSING = os.environ.get('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true' + # When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path}) # is blocked. Enable only if you need direct passthrough to upstream OpenAI- # compatible APIs for endpoints not natively handled by Open WebUI. diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 37a9011aab..8bfc2c2b08 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -34,8 +34,10 @@ from open_webui.utils.plugin import ( load_function_module_by_id, get_function_module_from_cache, ) +from open_webui.utils.access_control import check_model_access -from open_webui.env import GLOBAL_LOG_LEVEL +from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.utils.misc import ( add_or_update_system_message, @@ -260,6 +262,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di if model_info.base_model_id: form_data['model'] = model_info.base_model_id + if not BYPASS_MODEL_ACCESS_CONTROL: + bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass) + params = model_info.params.model_dump() if params: diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index db59d8b5f8..9028a3a4c8 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -24,6 +24,12 @@ from open_webui.env import ( DATABASE_POOL_TIMEOUT, DATABASE_ENABLE_SQLITE_WAL, DATABASE_ENABLE_SESSION_SHARING, + DATABASE_SQLITE_PRAGMA_SYNCHRONOUS, + DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT, + DATABASE_SQLITE_PRAGMA_CACHE_SIZE, + DATABASE_SQLITE_PRAGMA_TEMP_STORE, + DATABASE_SQLITE_PRAGMA_MMAP_SIZE, + DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT, ENABLE_DB_MIGRATIONS, ) from peewee_migrate import Router @@ -370,14 +376,32 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): elif 'sqlite' in SQLALCHEMY_DATABASE_URL: engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}) - def on_connect(dbapi_connection, connection_record): + def _apply_sqlite_pragmas(dbapi_connection): + """Apply all configured SQLite PRAGMAs to a raw DBAPI connection.""" cursor = dbapi_connection.cursor() if DATABASE_ENABLE_SQLITE_WAL: cursor.execute('PRAGMA journal_mode=WAL') else: cursor.execute('PRAGMA journal_mode=DELETE') + + # Each PRAGMA is skipped when its env var is empty, allowing opt-out. + if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS: + cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}') + if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT: + cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}') + if DATABASE_SQLITE_PRAGMA_CACHE_SIZE: + cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}') + if DATABASE_SQLITE_PRAGMA_TEMP_STORE: + cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}') + if DATABASE_SQLITE_PRAGMA_MMAP_SIZE: + cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}') + if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT: + cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}') cursor.close() + def on_connect(dbapi_connection, connection_record): + _apply_sqlite_pragmas(dbapi_connection) + event.listen(engine, 'connect', on_connect) elif DATABASE_ENABLE_IAM_TOKEN_AUTH: engine = rds_iam_config.create_engine() @@ -416,15 +440,10 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: ASYNC_SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}, ) - - if DATABASE_ENABLE_SQLITE_WAL: - - @event.listens_for(async_engine.sync_engine, 'connect') - def _set_sqlite_wal(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute('PRAGMA journal_mode=WAL') - cursor.close() - + + @event.listens_for(async_engine.sync_engine, 'connect') + def _set_sqlite_pragmas(dbapi_connection, connection_record): + _apply_sqlite_pragmas(dbapi_connection) elif DATABASE_ENABLE_IAM_TOKEN_AUTH: async_engine = rds_iam_config.create_engine(is_async=True) event.listen(async_engine.sync_engine, 'do_connect', rds_iam_config.check_token) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 8c2a139dba..8d95f5b277 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -21,7 +21,7 @@ from typing import Optional from aiocache import cached import aiohttp import anyio.to_thread -import requests + from redis import Redis @@ -46,7 +46,6 @@ from fastapi.staticfiles import StaticFiles from starlette_compress import CompressMiddleware 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 @@ -58,8 +57,15 @@ from starsessions import ( from starsessions.stores.redis import RedisStore from open_webui.utils import logger +from open_webui.utils.asgi_middleware import ( + AuthTokenMiddleware, + CommitSessionMiddleware, + RedirectMiddleware, + WebsocketUpgradeGuardMiddleware, +) from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware from open_webui.utils.logger import start_logger +from open_webui.utils.session_pool import get_session from open_webui.socket.main import ( MODELS, app as socket_app, @@ -115,7 +121,7 @@ from open_webui.internal.db import ScopedSession, engine, get_async_session from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel, Users -from open_webui.models.chats import Chats +from open_webui.models.chats import Chats, ChatForm from open_webui.config import ( # Ollama @@ -242,6 +248,7 @@ from open_webui.config import ( RAG_EXTERNAL_RERANKER_URL, RAG_EXTERNAL_RERANKER_API_KEY, RAG_EXTERNAL_RERANKER_TIMEOUT, + RAG_RERANKING_BATCH_SIZE, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, @@ -379,6 +386,7 @@ from open_webui.config import ( JWT_EXPIRES_IN, ENABLE_SIGNUP, ENABLE_LOGIN_FORM, + ENABLE_PASSWORD_CHANGE_FORM, ENABLE_API_KEYS, ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, API_KEYS_ALLOWED_ENDPOINTS, @@ -470,6 +478,7 @@ from open_webui.config import ( AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, AppConfig, reset_config, + async_reset_config, ) from open_webui.env import ( ENABLE_CUSTOM_MODEL_FALLBACK, @@ -555,6 +564,7 @@ from open_webui.utils.oauth import ( get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, + resolve_oauth_client_info, OAuthManager, OAuthClientManager, OAuthClientInformationFull, @@ -574,7 +584,7 @@ from open_webui.tasks import ( from open_webui.utils.redis import get_sentinels_from_env -from open_webui.constants import ERROR_MESSAGES +from open_webui.constants import ERROR_MESSAGES, TASKS if SAFE_MODE: print('SAFE MODE ENABLED') @@ -625,7 +635,7 @@ async def lifespan(app: FastAPI): start_logger() if RESET_CONFIG_ON_START: - reset_config() + await async_reset_config() if LICENSE_KEY: get_license_data(app, LICENSE_KEY) @@ -853,6 +863,7 @@ app.state.BASE_MODELS = [] app.state.config.WEBUI_URL = WEBUI_URL app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM +app.state.config.ENABLE_PASSWORD_CHANGE_FORM = ENABLE_PASSWORD_CHANGE_FORM app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS @@ -1034,6 +1045,7 @@ app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT +app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE app.state.config.RAG_TEMPLATE = RAG_TEMPLATE @@ -1183,6 +1195,7 @@ app.state.RERANKING_FUNCTION = get_reranking_function( app.state.config.RAG_RERANKING_ENGINE, app.state.config.RAG_RERANKING_MODEL, reranking_function=app.state.rf, + reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE, ) ######################################## @@ -1354,104 +1367,19 @@ if ENABLE_COMPRESSION_MIDDLEWARE: app.add_middleware(CompressMiddleware) -class RedirectMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - # Check if the request is a GET request - if request.method == 'GET': - path = request.url.path - query_params = dict(parse_qs(urlparse(str(request.url)).query)) - - redirect_params = {} - - # Check for the specific watch path and the presence of 'v' parameter - if path.endswith('/watch') and 'v' in query_params: - # Extract the first 'v' parameter - youtube_video_id = query_params['v'][0] - redirect_params['youtube'] = youtube_video_id - - if 'shared' in query_params and len(query_params['shared']) > 0: - # PWA share_target support - - text = query_params['shared'][0] - if text: - urls = re.match(r'https://\S+', text) - if urls: - from open_webui.retrieval.loaders.youtube import _parse_video_id - - if youtube_video_id := _parse_video_id(urls[0]): - redirect_params['youtube'] = youtube_video_id - else: - redirect_params['load-url'] = urls[0] - else: - redirect_params['q'] = text - - if redirect_params: - redirect_url = f'/?{urlencode(redirect_params)}' - return RedirectResponse(url=redirect_url) - - # Proceed with the normal flow of other requests - response = await call_next(request) - return response - - +# All HTTP middlewares below are pure-ASGI implementations. The previous +# `BaseHTTPMiddleware` / `@app.middleware('http')` versions wrapped the +# downstream app in an anyio task group whose cancel scope cancelled +# in-flight DB calls (and any other awaits) on client disconnect / +# response completion — which surfaced as noisy SQLAlchemy +# `terminate_force_close` tracebacks under aiosqlite and as random +# CancelledError storms across the request path. See +# `open_webui.utils.asgi_middleware` for the rationale. app.add_middleware(RedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) - - -@app.middleware('http') -async def commit_session_after_request(request: Request, call_next): - response = await call_next(request) - # log.debug("Commit session after request") - try: - ScopedSession.commit() - finally: - # CRITICAL: remove() returns the connection to the pool. - # Without this, connections remain "checked out" and accumulate - # as "idle in transaction" in PostgreSQL. - ScopedSession.remove() - return response - - -@app.middleware('http') -async def check_url(request: Request, call_next): - start_time = int(time.time()) - request.state.token = get_http_authorization_cred(request.headers.get('Authorization')) - # Fallback to cookie token for browser sessions - if request.state.token is None and request.cookies.get('token'): - from fastapi.security import HTTPAuthorizationCredentials - - request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=request.cookies.get('token')) - - # Fallback to x-api-key header for Anthropic Messages API routes - if request.state.token is None and request.headers.get('x-api-key'): - request_path = request.url.path - if request_path in ('/api/message', '/api/v1/messages') or request_path.startswith('/ollama/v1/messages'): - from fastapi.security import HTTPAuthorizationCredentials - - request.state.token = HTTPAuthorizationCredentials( - scheme='Bearer', credentials=request.headers.get('x-api-key') - ) - - request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS - response = await call_next(request) - process_time = int(time.time()) - start_time - response.headers['X-Process-Time'] = str(process_time) - return response - - -@app.middleware('http') -async def inspect_websocket(request: Request, call_next): - if '/ws/socket.io' in request.url.path and request.query_params.get('transport') == 'websocket': - upgrade = (request.headers.get('Upgrade') or '').lower() - connection = (request.headers.get('Connection') or '').lower().split(',') - # Check that there's the correct headers for an upgrade, else reject the connection - # This is to work around this upstream issue: https://github.com/miguelgrinberg/python-engineio/issues/367 - if upgrade != 'websocket' or 'upgrade' not in connection: - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={'detail': 'Invalid WebSocket upgrade request'}, - ) - return await call_next(request) +app.add_middleware(CommitSessionMiddleware) +app.add_middleware(AuthTokenMiddleware, fastapi_app=app) +app.add_middleware(WebsocketUpgradeGuardMiddleware) app.add_middleware( @@ -1691,13 +1619,30 @@ async def chat_completion( if model_info_params.get('reasoning_tags') is not None: reasoning_tags = model_info_params.get('reasoning_tags') + # parent_id signals intent: + # null → new chat (root message, no parent) + # value → follow-up (user message's parentId = prev assistant) + # absent → legacy caller, no chat management + is_new_chat = 'parent_id' in form_data and form_data['parent_id'] is None and not form_data.get('chat_id') + parent_id = form_data.pop('parent_id', None) + form_data.pop('new_chat', None) # Legacy field + + # Multi-model: {model_id: assistant_message_id} + # Single-model fallback: built from 'model' + 'id' + message_ids = form_data.pop('message_ids', None) + if not message_ids: + message_ids = {model_id: form_data.pop('id', None)} + else: + form_data.pop('id', None) + + user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) metadata = { 'user_id': user.id, 'chat_id': form_data.pop('chat_id', None), - 'message_id': form_data.pop('id', None), - 'parent_message': form_data.pop('parent_message', None), - 'parent_message_id': form_data.pop('parent_id', None), + 'user_message': user_message, + 'user_message_id': user_message.get('id') if user_message else None, 'session_id': form_data.pop('session_id', None), + 'folder_id': form_data.pop('folder_id', None), 'filter_ids': form_data.pop('filter_ids', []), 'tool_ids': form_data.get('tool_ids', None), 'tool_servers': form_data.pop('tool_servers', None), @@ -1720,40 +1665,166 @@ async def chat_completion( }, } + if is_new_chat: + metadata['chat_id'] = str(uuid4()) + if metadata.get('chat_id') and user: - if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored - # Verify chat ownership — lightweight EXISTS check avoids - # deserializing the full chat JSON blob just to confirm the row exists - if ( - not await Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin' - ): # admins can access any chat - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.DEFAULT(), + chat_id = metadata['chat_id'] + if not chat_id.startswith('local:'): # temporary chats are not stored + if is_new_chat: + # Build the full history upfront with ALL assistant placeholders + user_message = metadata.get('user_message') or {} + user_message_id = user_message.get('id') if user_message else None + + history_messages = {} + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id] + + if user_message_id and user_message: + user_message['childrenIds'] = all_assistant_ids + history_messages[user_message_id] = user_message + + for target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + history_messages[assistant_message_id] = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + } + + await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'New Chat', + 'models': list(message_ids.keys()), + 'history': { + 'currentId': all_assistant_ids[0] if all_assistant_ids else user_message_id, + 'messages': history_messages, + }, + 'messages': [ + {'role': 'user', 'content': user_message.get('content', '')}, + ] + if user_message_id + else [], + 'tags': [], + 'timestamp': int(time.time() * 1000), + }, + folder_id=metadata.get('folder_id'), + ), ) - # Insert chat files from parent message if any - parent_message = metadata.get('parent_message') or {} - parent_message_files = parent_message.get('files', []) - if parent_message_files: - try: - await Chats.insert_chat_files( - metadata['chat_id'], - parent_message.get('id'), - [ - file_item.get('id') - for file_item in parent_message_files - if file_item.get('type') == 'file' - ], - user.id, + # Insert chat files from user message if any + user_message_files = user_message.get('files', []) + if user_message_files: + try: + await Chats.insert_chat_files( + chat_id, + user_message_id, + [ + file_item.get('id') + for file_item in user_message_files + if file_item.get('type') == 'file' + ], + user.id, + ) + except Exception as e: + log.debug(f'Error inserting chat files: {e}') + pass + else: + # Existing chat — verify ownership + if not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.DEFAULT(), ) - except Exception as e: - log.debug(f'Error inserting chat files: {e}') - pass + + # Save user message to DB + user_message = metadata.get('user_message') or {} + if user_message and user_message.get('id'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + user_message['id'], + user_message, + ) + + # Link grandparent → user message (childrenIds) + grandparent_id = user_message.get('parentId') + if grandparent_id: + grandparent = await Chats.get_message_by_id_and_message_id(chat_id, grandparent_id) + if grandparent: + child_ids = grandparent.get('childrenIds', []) + if user_message['id'] not in child_ids: + child_ids.append(user_message['id']) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, grandparent_id, {'childrenIds': child_ids} + ) + + # Insert chat files from user message if any + user_message_files = user_message.get('files', []) + if user_message_files: + try: + await Chats.insert_chat_files( + chat_id, + user_message.get('id'), + [ + file_item.get('id') + for file_item in user_message_files + if file_item.get('type') == 'file' + ], + user.id, + ) + except Exception as e: + log.debug(f'Error inserting chat files: {e}') + pass + + # Save ALL assistant placeholders + user_message_id = metadata.get('user_message_id') + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id] + + # Link user message → all assistant messages (childrenIds) + if user_message_id and all_assistant_ids: + existing_user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id) + if existing_user_message: + child_ids = existing_user_message.get('childrenIds', []) + for assistant_id in all_assistant_ids: + if assistant_id not in child_ids: + child_ids.append(assistant_id) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + user_message_id, + {'childrenIds': child_ids}, + ) + + # Save each assistant placeholder + for target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + assistant_message_id, + { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + }, + ) request.state.metadata = metadata form_data['metadata'] = metadata + except HTTPException: + raise except Exception as e: log.debug(f'Error processing chat metadata: {e}') raise HTTPException( @@ -1761,24 +1832,26 @@ async def chat_completion( detail=str(e), ) - async def process_chat(request, form_data, user, metadata, model): + async def process_chat(request, form_data, user, metadata, model, tasks=None): try: form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) response = await chat_completion_handler(request, form_data, user) - if metadata.get('chat_id') and metadata.get('message_id'): + + # When the upstream provider returns an error (e.g. HTTP 400 + # content-filter, quota exceeded), generate_chat_completion + # returns a JSONResponse instead of raising. Detect this and + # raise so the except-block below emits chat:message:error + + # chat:tasks:cancel, unblocking the frontend. + if isinstance(response, JSONResponse) and response.status_code >= 400: try: - if not metadata['chat_id'].startswith('local:'): - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'parentId': metadata.get('parent_message_id', None), - 'model': model_id, - }, - ) + error_body = json.loads(response.body.decode('utf-8', 'replace')) + detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body + if isinstance(detail, dict): + detail = detail.get('message', detail.get('detail', str(detail))) except Exception: - pass + detail = f'Provider returned HTTP {response.status_code}' + raise Exception(detail) ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events) @@ -1787,11 +1860,12 @@ async def chat_completion( log.info('Chat processing was cancelled') try: event_emitter = await get_event_emitter(metadata) - await asyncio.shield( - event_emitter( - {'type': 'chat:tasks:cancel'}, + if event_emitter: + await asyncio.shield( + event_emitter( + {'type': 'chat:tasks:cancel'}, + ) ) - ) except Exception as e: pass finally: @@ -1806,21 +1880,22 @@ async def chat_completion( metadata['chat_id'], metadata['message_id'], { - 'parentId': metadata.get('parent_message_id', None), + 'parentId': metadata.get('user_message_id', None), 'error': {'content': str(e)}, }, ) event_emitter = await get_event_emitter(metadata) - await event_emitter( - { - 'type': 'chat:message:error', - 'data': {'error': {'content': str(e)}}, - } - ) - await event_emitter( - {'type': 'chat:tasks:cancel'}, - ) + if event_emitter: + await event_emitter( + { + 'type': 'chat:message:error', + 'data': {'error': {'content': str(e)}}, + } + ) + await event_emitter( + {'type': 'chat:tasks:cancel'}, + ) except Exception: pass @@ -1856,20 +1931,72 @@ async def chat_completion( except Exception as e: log.debug(f'Error emitting chat:active: {e}') - if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): - # Asynchronous Chat Processing - task_id, _ = await create_task( - request.app.state.redis, - process_chat(request, form_data, user, metadata, model), - id=metadata['chat_id'], - ) - # Emit chat:active=true when task starts - event_emitter = await get_event_emitter(metadata, update_db=False) - if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': True}}) - return {'status': True, 'task_id': task_id} + # Fan out: one task per model + if metadata.get('session_id') and metadata.get('chat_id'): + task_ids = [] + chat_id = metadata['chat_id'] + + for idx, (target_model_id, assistant_message_id) in enumerate(message_ids.items()): + if not assistant_message_id: + continue + + # Per-model metadata: own message_id + model + per_model_metadata = { + **metadata, + 'message_id': assistant_message_id, + } + + # Per-model form_data: own model + model_form_data = { + **form_data, + 'model': target_model_id, + 'metadata': per_model_metadata, + } + + # Resolve the model object for this specific model + resolved_model = request.app.state.MODELS.get(target_model_id, model) + + # Only the first model runs title/tags generation; + # subsequent models only run follow-ups. + task_id, _ = await create_task( + request.app.state.redis, + process_chat( + request, + model_form_data, + user, + per_model_metadata, + resolved_model, + tasks + if idx == 0 + else { + k: v + for k, v in (tasks or {}).items() + if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION) + } + or None, + ), + id=chat_id, + ) + task_ids.append(task_id) + + # Emit chat:active=true + if task_ids: + event_emitter = await get_event_emitter( + {**metadata, 'message_id': list(message_ids.values())[0]}, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:active', 'data': {'active': True}}) + + return { + 'status': True, + 'task_ids': task_ids, + 'chat_id': chat_id, + } else: - return await process_chat(request, form_data, user, metadata, model) + # Legacy/direct: single model, synchronous + metadata['message_id'] = list(message_ids.values())[0] + return await process_chat(request, form_data, user, metadata, model, tasks) # Alias for chat_completion (Legacy) @@ -1943,6 +2070,8 @@ async def generate_messages( @app.post('/api/chat/completed') async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)): + """Deprecated: outlet filters now run inline during chat completion. + Kept for backward compatibility with external integrations.""" try: model_item = form_data.pop('model_item', {}) @@ -2076,6 +2205,7 @@ async def get_app_config(request: Request): 'enable_api_keys': app.state.config.ENABLE_API_KEYS, 'enable_signup': app.state.config.ENABLE_SIGNUP, 'enable_login_form': app.state.config.ENABLE_LOGIN_FORM, + 'enable_password_change_form': app.state.config.ENABLE_PASSWORD_CHANGE_FORM, 'enable_websocket': ENABLE_WEBSOCKET_SUPPORT, 'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK, 'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT, @@ -2286,10 +2416,8 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: auth_type = tool_server_connection.get('auth_type', 'none') if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): - oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '') - try: - oauth_client_info = decrypt_data(oauth_client_info) + oauth_client_info = resolve_oauth_client_info(tool_server_connection) app.state.oauth_client_manager.add_client( f'mcp:{server_id}', OAuthClientInformationFull(**oauth_client_info), @@ -2350,18 +2478,25 @@ async def register_client(request, client_id: str) -> bool: try: if auth_type == 'oauth_2.1_static': - # Static credentials: rebuild from stored credentials + fresh metadata - existing_client_info = connection.get('info', {}).get('oauth_client_info', '') - if not existing_client_info: - log.error(f'No stored OAuth client info for static client {client_id}') - return False - existing_data = decrypt_data(existing_client_info) + # Static credentials: rebuild from admin-provided credentials + fresh metadata + info = connection.get('info', {}) + oauth_client_id = info.get('oauth_client_id') or '' + oauth_client_secret = info.get('oauth_client_secret') or '' + if not oauth_client_id or not oauth_client_secret: + # Fall back to blob for backward compatibility + existing_client_info = info.get('oauth_client_info', '') + if not existing_client_info: + log.error(f'No stored OAuth client info for static client {client_id}') + return False + existing_data = decrypt_data(existing_client_info) + oauth_client_id = oauth_client_id or existing_data.get('client_id', '') + oauth_client_secret = oauth_client_secret or existing_data.get('client_secret', '') oauth_client_info = await get_oauth_client_info_with_static_credentials( request, client_id, server_url, - oauth_client_id=existing_data.get('client_id', ''), - oauth_client_secret=existing_data.get('client_secret', ''), + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( @@ -2494,7 +2629,13 @@ async def oauth_backchannel_logout( @app.get('/manifest.json') async def get_manifest_json(): if app.state.EXTERNAL_PWA_MANIFEST_URL: - return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json() + session = await get_session() + async with session.get( + app.state.EXTERNAL_PWA_MANIFEST_URL, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() else: return { 'name': app.state.WEBUI_NAME, diff --git a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py new file mode 100644 index 0000000000..2451f50ae2 --- /dev/null +++ b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py @@ -0,0 +1,164 @@ +"""Add shared_chat table and migrate existing shares + +Revision ID: c1d2e3f4a5b6 +Revises: e1f2a3b4c5d6 +Create Date: 2026-04-16 23:00:00.000000 + +""" + +import time +import uuid + +from alembic import op +import sqlalchemy as sa + +revision = 'c1d2e3f4a5b6' +down_revision = 'e1f2a3b4c5d6' +branch_labels = None +depends_on = None + +# Lightweight table references for data migration (no ORM models needed) +chat_t = sa.table( + 'chat', + sa.column('id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('share_id', sa.Text), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), + sa.column('archived', sa.Boolean), + sa.column('meta', sa.JSON), +) + +shared_chat_t = sa.table( + 'shared_chat', + sa.column('id', sa.Text), + sa.column('chat_id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), +) + +chat_message_t = sa.table( + 'chat_message', + sa.column('chat_id', sa.Text), +) + +access_grant_t = sa.table( + 'access_grant', + sa.column('id', sa.Text), + sa.column('resource_type', sa.Text), + sa.column('resource_id', sa.Text), + sa.column('principal_type', sa.Text), + sa.column('principal_id', sa.Text), + sa.column('permission', sa.Text), + sa.column('created_at', sa.BigInteger), +) + + +def upgrade(): + conn = op.get_bind() + + # 1. Create shared_chat table + op.create_table( + 'shared_chat', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('chat', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + ) + + # 2. Migrate existing shared-* rows + shared_rows = conn.execute( + sa.select( + chat_t.c.id, + chat_t.c.user_id, + chat_t.c.title, + chat_t.c.chat, + chat_t.c.created_at, + chat_t.c.updated_at, + ).where(chat_t.c.user_id.like('shared-%')) + ).fetchall() + + for row in shared_rows: + share_token = row.id + original_chat_id = row.user_id.replace('shared-', '', 1) + + # Verify original chat still exists + original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone() + + if not original: + continue + + # Insert snapshot into shared_chat + conn.execute( + shared_chat_t.insert().values( + id=share_token, + chat_id=original_chat_id, + user_id=original.user_id, + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + ) + ) + + # Create user:*:read grant for backward compat + conn.execute( + access_grant_t.insert().values( + id=str(uuid.uuid4()), + resource_type='shared_chat', + resource_id=original_chat_id, + principal_type='user', + principal_id='*', + permission='read', + created_at=row.created_at or int(time.time()), + ) + ) + + # 3. Clean up old phantom rows + conn.execute( + chat_message_t.delete().where( + chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%'))) + ) + ) + conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%'))) + + +def downgrade(): + conn = op.get_bind() + + shared_rows = conn.execute( + sa.select( + shared_chat_t.c.id, + shared_chat_t.c.chat_id, + shared_chat_t.c.user_id, + shared_chat_t.c.title, + shared_chat_t.c.chat, + shared_chat_t.c.created_at, + shared_chat_t.c.updated_at, + ) + ).fetchall() + + for row in shared_rows: + conn.execute( + chat_t.insert().values( + id=row.id, + user_id=f'shared-{row.chat_id}', + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + archived=False, + meta={}, + ) + ) + + conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat')) + op.drop_table('shared_chat') diff --git a/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py new file mode 100644 index 0000000000..0d80558746 --- /dev/null +++ b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py @@ -0,0 +1,23 @@ +"""Add is_pinned to note table + +Revision ID: e1f2a3b4c5d6 +Revises: b7c8d9e0f1a2 +Create Date: 2026-04-14 22:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'e1f2a3b4c5d6' +down_revision = 'b7c8d9e0f1a2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('note', 'is_pinned') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index b13edbf0bf..ba6611a811 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -293,10 +293,9 @@ class ChatTable: return changed async def insert_new_chat( - self, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None + self, id: str, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: async with get_async_db_context(db) as db: - id = str(uuid.uuid4()) chat = ChatModel( **{ 'id': id, @@ -556,77 +555,51 @@ class ChatTable: async def insert_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Create a shared snapshot for a chat. Returns the original chat with share_id set.""" + from open_webui.models.shared_chats import SharedChats + async with get_async_db_context(db) as db: - # Get the existing chat to share chat = await db.get(Chat, chat_id) - # Check if chat exists if not chat: return None - # Check if the chat is already shared - if chat.share_id: - return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db) - # Create a new chat with the same data, but with a new ID - shared_chat = ChatModel( - **{ - 'id': str(uuid.uuid4()), - 'user_id': f'shared-{chat_id}', - 'title': chat.title, - 'chat': chat.chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - 'created_at': chat.created_at, - 'updated_at': int(time.time()), - } - ) - shared_result = Chat(**shared_chat.model_dump()) - db.add(shared_result) - await db.commit() - await db.refresh(shared_result) - # Update the original chat with the share_id - await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id)) + # If already shared, just update the existing snapshot + if chat.share_id: + return await self.update_shared_chat_by_chat_id(chat_id, db=db) + + shared = await SharedChats.create(chat_id, chat.user_id, db=db) + if not shared: + return None + + # Set share_id on the original chat + chat.share_id = shared.id await db.commit() - return shared_chat if shared_result else None + await db.refresh(chat) + return ChatModel.model_validate(chat) async def update_shared_chat_by_chat_id( self, chat_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: + """Re-snapshot the shared chat with current chat data.""" + from open_webui.models.shared_chats import SharedChats + try: async with get_async_db_context(db) as db: chat = await db.get(Chat, chat_id) - result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}')) - shared_chat = result.scalars().first() - - if shared_chat is None: + if not chat or not chat.share_id: return await self.insert_shared_chat_by_chat_id(chat_id, db=db) - shared_chat.title = chat.title - shared_chat.chat = chat.chat - shared_chat.meta = chat.meta - shared_chat.pinned = chat.pinned - shared_chat.folder_id = chat.folder_id - shared_chat.updated_at = int(time.time()) - await db.commit() - await db.refresh(shared_chat) - - return ChatModel.model_validate(shared_chat) + await SharedChats.update(chat.share_id, db=db) + return ChatModel.model_validate(chat) except Exception: return None async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete shared snapshot for a chat.""" + from open_webui.models.shared_chats import SharedChats + try: - async with get_async_db_context(db) as db: - # Get shared chat IDs - result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}')) - shared_ids = [row[0] for row in result.all()] - - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}')) - await db.commit() - - return True + return await SharedChats.delete_by_chat_id(chat_id, db=db) except Exception: return False @@ -747,53 +720,10 @@ class ChatTable: limit: int = 50, db: Optional[AsyncSession] = None, ) -> list[SharedChatResponse]: - async with get_async_db_context(db) as db: - stmt = ( - select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at) - .filter_by(user_id=user_id) - .filter(Chat.share_id.isnot(None)) - ) + """Delegate to SharedChats for listing shared chats by user.""" + from open_webui.models.shared_chats import SharedChats - if filter: - query_key = filter.get('query') - if query_key: - stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%')) - - order_by = filter.get('order_by') - direction = filter.get('direction') - - if order_by and direction: - if not getattr(Chat, order_by, None): - raise ValueError('Invalid order_by field') - - if direction.lower() == 'asc': - stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == 'desc': - stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) - else: - raise ValueError('Invalid direction for ordering') - else: - stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) - - if skip: - stmt = stmt.offset(skip) - if limit: - stmt = stmt.limit(limit) - - result = await db.execute(stmt) - all_chats = result.all() - return [ - SharedChatResponse.model_validate( - { - 'id': chat[0], - 'title': chat[1], - 'share_id': chat[2], - 'updated_at': chat[3], - 'created_at': chat[4], - } - ) - for chat in all_chats - ] + return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db) async def get_chat_list_by_user_id( self, @@ -926,15 +856,23 @@ class ChatTable: return None async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: - try: - async with get_async_db_context(db) as db: - result = await db.execute(select(Chat).filter_by(share_id=id)) - chat = result.scalars().first() + """Look up a shared chat snapshot by its share token.""" + from open_webui.models.shared_chats import SharedChats - if chat: - return await self.get_chat_by_id(id, db=db) - else: - return None + try: + shared = await SharedChats.get_by_id(id, db=db) + if shared: + # Return a ChatModel-compatible view of the snapshot + return ChatModel( + id=shared.id, + user_id=shared.user_id, + title=shared.title, + chat=shared.chat, + created_at=shared.created_at, + updated_at=shared.updated_at, + share_id=shared.id, + ) + return None except Exception: return None @@ -1324,8 +1262,10 @@ class ChatTable: self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> list[TagModel]: async with get_async_db_context(db) as db: - chat = await db.get(Chat, id) - tag_ids = chat.meta.get('tags', []) + stmt = select(Chat.meta).where(Chat.id == id) + result = await db.execute(stmt) + meta = result.scalar_one_or_none() + tag_ids = (meta or {}).get('tags', []) return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db) async def get_chat_list_by_user_id_and_tag_name( @@ -1569,20 +1509,17 @@ class ChatTable: return False async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chat snapshots created by a user.""" + from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable + try: async with get_async_db_context(db) as db: - result = await db.execute(select(Chat.id).filter_by(user_id=user_id)) - id_rows = result.all() - shared_chat_ids = [f'shared-{row[0]}' for row in id_rows] + # Delete shared_chat rows for this user's chats + await db.execute(delete(SharedChatTable).filter_by(user_id=user_id)) - if shared_chat_ids: - # Get shared chat IDs to delete associated messages - shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids))) - shared_ids = [row[0] for row in shared_result.all()] - if shared_ids: - await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids))) - await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids))) - await db.commit() + # Clear share_id on all of this user's chats + await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None)) + await db.commit() return True except Exception: @@ -1599,11 +1536,11 @@ class ChatTable: if not file_ids: return None - chat_message_file_ids = [ + chat_message_file_ids = { item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db) - ] + } # Remove duplicates and existing file_ids - file_ids = list(set([file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids])) + file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids}) if not file_ids: return None @@ -1652,16 +1589,15 @@ class ChatTable: except Exception: return False - async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]: + async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]: + """Return IDs of chats that contain this file and have an active share link.""" async with get_async_db_context(db) as db: result = await db.execute( - select(Chat) + select(Chat.id) .join(ChatFile, Chat.id == ChatFile.chat_id) .filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None)) ) - all_chats = result.scalars().all() - - return [ChatModel.model_validate(chat) for chat in all_chats] + return [row[0] for row in result.all()] async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: """Update the tasks list on a chat.""" diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 47dbe195ab..c553239482 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -74,14 +74,14 @@ class FolderForm(BaseModel): data: Optional[dict] = None meta: Optional[dict] = None parent_id: Optional[str] = None - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='forbid') class FolderUpdateForm(BaseModel): name: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='forbid') class FolderTable: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 25a7905800..1a34750a7d 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -4,7 +4,7 @@ import uuid from typing import Optional from functools import lru_cache -from sqlalchemy import select, delete, update, or_, func, cast +from sqlalchemy import Boolean, select, delete, update, or_, func, cast from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import Base, get_async_db_context from open_webui.models.groups import Groups @@ -29,6 +29,7 @@ class Note(Base): title = Column(Text) data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) + is_pinned = Column(Boolean, default=False, nullable=True) created_at = Column(BigInteger) updated_at = Column(BigInteger) @@ -43,6 +44,7 @@ class NoteModel(BaseModel): title: str data: Optional[dict] = None meta: Optional[dict] = None + is_pinned: Optional[bool] = False access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -77,6 +79,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -311,6 +314,39 @@ class NoteTable: await db.commit() return await self._to_note_model(note, db=db) if note else None + async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]: + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Note).filter(Note.id == id)) + note = result.scalars().first() + if not note: + return None + note.is_pinned = not note.is_pinned + note.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_note_model(note, db=db) + except Exception: + return None + + async def get_pinned_notes_by_user_id( + self, + user_id: str, + permission: str = 'read', + db: Optional[AsyncSession] = None, + ) -> list[NoteModel]: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc()) + stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission) + + result = await db.execute(stmt) + notes = result.scalars().all() + note_ids = [note.id for note in notes] + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] + async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 84e5c66560..050a50d486 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -123,7 +123,7 @@ class OAuthSessionTable: 'user_id': user_id, 'provider': provider, 'token': self._encrypt_token(token), - 'expires_at': token.get('expires_at'), + 'expires_at': token.get('expires_at') or int(time.time() + 3600), 'created_at': current_time, 'updated_at': current_time, } @@ -274,7 +274,7 @@ class OAuthSessionTable: .filter_by(id=session_id) .values( token=self._encrypt_token(token), - expires_at=token.get('expires_at'), + expires_at=token.get('expires_at') or int(time.time() + 3600), updated_at=current_time, ) ) diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index fbf5401203..dec1848aa7 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -3,7 +3,7 @@ import time import uuid from typing import Optional -from sqlalchemy import select, delete, update, or_, func, cast, String +from sqlalchemy import select, delete, update, or_, func, text, cast, String from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.groups import Groups @@ -260,12 +260,12 @@ class PromptsTable: ) -> PromptListResponse: async with get_async_db_context(db) as db: # Join with User table for user filtering and sorting - stmt = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id) + query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id) if filter: query_key = filter.get('query') if query_key: - stmt = stmt.filter( + query = query.filter( or_( Prompt.name.ilike(f'%{query_key}%'), Prompt.command.ilike(f'%{query_key}%'), @@ -277,14 +277,14 @@ class PromptsTable: view_option = filter.get('view_option') if view_option == 'created': - stmt = stmt.filter(Prompt.user_id == user_id) + query = query.filter(Prompt.user_id == user_id) elif view_option == 'shared': - stmt = stmt.filter(Prompt.user_id != user_id) + query = query.filter(Prompt.user_id != user_id) # Apply access grant filtering - stmt = AccessGrants.has_permission_filter( + query = AccessGrants.has_permission_filter( db=db, - query=stmt, + query=query, DocumentModel=Prompt, filter=filter, resource_type='prompt', @@ -293,56 +293,63 @@ class PromptsTable: tag = filter.get('tag') if tag: - # SQLite stores JSON text via json.dumps(ensure_ascii=True), - # so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB - # stores literal Unicode. Use the right pattern for each. - if db.bind.dialect.name == 'sqlite': - if tag.isascii(): - tags_text = func.lower(cast(Prompt.tags, String)) - pattern = f'%{json.dumps(tag.lower())}%' - else: - # LOWER() is ASCII-only; non-ASCII codepoints would - # produce different \uXXXX escapes when lowered. - tags_text = cast(Prompt.tags, String) - pattern = f'%{json.dumps(tag)}%' + bind = await db.connection() + dialect_name = bind.dialect.name + tag_lower = tag.lower() + + if dialect_name == 'sqlite': + tag_clause = text( + 'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)' + ) + elif dialect_name == 'postgresql': + tag_clause = text( + 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' + ) else: - tags_text = func.lower(cast(Prompt.tags, String)) - pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%' - stmt = stmt.filter(tags_text.like(pattern)) + # Fallback: LIKE on serialised JSON text (ASCII-safe only) + tag_clause = func.lower(cast(Prompt.tags, String)).like( + f'%{json.dumps(tag_lower, ensure_ascii=False)}%' + ) + tag_lower = None + + if tag_lower is not None: + query = query.filter(tag_clause.params(tag_val=tag_lower)) + else: + query = query.filter(tag_clause) order_by = filter.get('order_by') direction = filter.get('direction') if order_by == 'name': if direction == 'asc': - stmt = stmt.order_by(Prompt.name.asc()) + query = query.order_by(Prompt.name.asc()) else: - stmt = stmt.order_by(Prompt.name.desc()) + query = query.order_by(Prompt.name.desc()) elif order_by == 'created_at': if direction == 'asc': - stmt = stmt.order_by(Prompt.created_at.asc()) + query = query.order_by(Prompt.created_at.asc()) else: - stmt = stmt.order_by(Prompt.created_at.desc()) + query = query.order_by(Prompt.created_at.desc()) elif order_by == 'updated_at': if direction == 'asc': - stmt = stmt.order_by(Prompt.updated_at.asc()) + query = query.order_by(Prompt.updated_at.asc()) else: - stmt = stmt.order_by(Prompt.updated_at.desc()) + query = query.order_by(Prompt.updated_at.desc()) else: - stmt = stmt.order_by(Prompt.updated_at.desc()) + query = query.order_by(Prompt.updated_at.desc()) else: - stmt = stmt.order_by(Prompt.updated_at.desc()) + query = query.order_by(Prompt.updated_at.desc()) # Count BEFORE pagination - count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + count_result = await db.execute(select(func.count()).select_from(query.subquery())) total = count_result.scalar() if skip: - stmt = stmt.offset(skip) + query = query.offset(skip) if limit: - stmt = stmt.limit(limit) + query = query.limit(limit) - result = await db.execute(stmt) + result = await db.execute(query) items = result.all() prompt_ids = [prompt.id for prompt, _ in items] diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py new file mode 100644 index 0000000000..37a3fea852 --- /dev/null +++ b/backend/open_webui/models/shared_chats.py @@ -0,0 +1,207 @@ +import logging +import time +import uuid +from typing import Optional + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON + +log = logging.getLogger(__name__) + +#################### +# SharedChat DB Schema +#################### + + +class SharedChat(Base): + __tablename__ = 'shared_chat' + + id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Text, nullable=False) # Who created this share + + title = Column(Text) + chat = Column(JSON) # Snapshot of chat JSON at share time + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class SharedChatModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + + title: str + chat: dict + + created_at: int + updated_at: int + + +class SharedChatResponse(BaseModel): + id: str + chat_id: str + title: str + share_id: Optional[str] = None # Alias for id, for backward compat + updated_at: int + created_at: int + + +#################### +# Table Operations +#################### + + +class SharedChatsTable: + async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """ + Create a snapshot of the chat for link sharing. + Returns the SharedChatModel with the share token as its id. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + chat = await db.get(Chat, chat_id) + if not chat: + return None + + share_id = str(uuid.uuid4()) + now = int(time.time()) + + shared_chat = SharedChat( + id=share_id, + chat_id=chat_id, + user_id=user_id, + title=chat.title, + chat=chat.chat, + created_at=now, + updated_at=now, + ) + db.add(shared_chat) + await db.commit() + await db.refresh(shared_chat) + + return SharedChatModel.model_validate(shared_chat) + + async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """ + Re-snapshot: update the shared chat with the current state of the original chat. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + shared_chat = await db.get(SharedChat, share_id) + if not shared_chat: + return None + + chat = await db.get(Chat, shared_chat.chat_id) + if not chat: + return None + + shared_chat.title = chat.title + shared_chat.chat = chat.chat + shared_chat.updated_at = int(time.time()) + + await db.commit() + await db.refresh(shared_chat) + return SharedChatModel.model_validate(shared_chat) + + async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """Get a shared chat by its share token.""" + async with get_async_db_context(db) as db: + shared_chat = await db.get(SharedChat, share_id) + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """Get the shared chat for a given original chat. Returns the most recent one.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1) + ) + shared_chat = result.scalars().first() + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_user_id( + self, + user_id: str, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 50, + db: Optional[AsyncSession] = None, + ) -> list[SharedChatResponse]: + """List all shared chats created by a user.""" + async with get_async_db_context(db) as db: + stmt = select(SharedChat).filter_by(user_id=user_id) + + if filter: + query_key = filter.get('query') + if query_key: + stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%')) + + order_by = filter.get('order_by') + direction = filter.get('direction') + + if order_by and direction: + col = getattr(SharedChat, order_by, None) + if not col: + raise ValueError('Invalid order_by field') + if direction.lower() == 'asc': + stmt = stmt.order_by(col.asc()) + elif direction.lower() == 'desc': + stmt = stmt.order_by(col.desc()) + else: + raise ValueError('Invalid direction for ordering') + else: + stmt = stmt.order_by(SharedChat.updated_at.desc()) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + return [ + SharedChatResponse( + id=sc.chat_id, + chat_id=sc.chat_id, + title=sc.title, + share_id=sc.id, + updated_at=sc.updated_at, + created_at=sc.created_at, + ) + for sc in result.scalars().all() + ] + + async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete a shared chat by its share token.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(id=share_id)) + await db.commit() + return True + except Exception: + return False + + async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chats for a given original chat.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(chat_id=chat_id)) + await db.commit() + return True + except Exception: + return False + + +SharedChats = SharedChatsTable() diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 57867d78f5..7dc9df37ce 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -1,3 +1,4 @@ +import asyncio import requests import logging import ftfy @@ -238,6 +239,18 @@ class Loader: return [Document(page_content=ftfy.fix_text(doc.page_content), metadata=doc.metadata) for doc in docs] + async def aload(self, filename: str, file_content_type: str, file_path: str) -> list[Document]: + """ + Async wrapper around `load`. + + Document loaders dispatched by `_get_loader` (PyMuPDF, Unstructured, + python-docx, Tika, etc.) are uniformly synchronous and CPU/IO-bound. + Calling `load` directly from an async handler would block the event + loop for the entire parse — minutes for large PDFs. This offloads + the work to a worker thread so the loop stays responsive. + """ + return await asyncio.to_thread(self.load, filename, file_content_type, file_path) + def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: return file_ext in known_source_ext or ( file_content_type diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index d122291ec5..ceb41824e3 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -59,14 +59,14 @@ class ColBERT(BaseReranker): return normalized_scores.detach().cpu().numpy().astype(np.float32) - def predict(self, sentences): + def predict(self, sentences, batch_size=32): query = sentences[0][0] docs = [i[1] for i in sentences] # Embedding the documents - embedded_docs = self.ckpt.docFromText(docs, bsize=32)[0] + embedded_docs = self.ckpt.docFromText(docs, bsize=batch_size)[0] # Embedding the queries - embedded_queries = self.ckpt.queryFromText([query], bsize=32) + embedded_queries = self.ckpt.queryFromText([query], bsize=batch_size) embedded_query = embedded_queries[0] # Calculate retrieval scores for the query against all documents diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index f7d2775c52..93ba72ce13 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -20,6 +20,7 @@ from langchain_community.retrievers import BM25Retriever from langchain_core.documents import Document from open_webui.config import VECTOR_DB +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT @@ -121,7 +122,7 @@ class VectorSearchRetriever(BaseRetriever): run_manager: CallbackManagerForRetrieverRun, ) -> list[Document]: embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) - result = VECTOR_DB_CLIENT.search( + result = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=self.collection_name, vectors=[embedding], limit=self.top_k, @@ -488,16 +489,24 @@ async def query_collection_with_hybrid_search( ) -> dict: results = [] error = False - # Fetch collection data once per collection sequentially - # Avoid fetching the same data multiple times later - collection_results = {} - for collection_name in collection_names: + # Fetch every collection's contents once up front so the + # per-query/per-document loop below can reuse them. Each fetch + # offloads to a worker thread, so run them concurrently with + # `asyncio.gather` instead of awaiting them serially — otherwise + # latency scales linearly with `len(collection_names)`. + log.debug( + 'query_collection_with_hybrid_search: prefetching %d collections', + len(collection_names), + ) + + async def _fetch_collection(name: str): try: - log.debug(f'query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}') - collection_results[collection_name] = VECTOR_DB_CLIENT.get(collection_name=collection_name) + return name, await ASYNC_VECTOR_DB_CLIENT.get(collection_name=name) except Exception as e: - log.exception(f'Failed to fetch collection {collection_name}: {e}') - collection_results[collection_name] = None + log.exception(f'Failed to fetch collection {name}: {e}') + return name, None + + collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names))) log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...') @@ -910,7 +919,7 @@ async def generate_embeddings( return embeddings[0] if isinstance(text, str) else embeddings -def get_reranking_function(reranking_engine, reranking_model, reranking_function): +def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32): if reranking_function is None: return None if reranking_engine == 'external': @@ -919,10 +928,61 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function ) else: return lambda query, documents, user=None: reranking_function.predict( - [(query, doc.page_content) for doc in documents] + [(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size) ) +async def filter_accessible_collections( + collection_names: set[str], + user: UserModel, + access_type: str = 'read', +) -> set[str]: + """ + Return only the collection names the user is allowed to access. + Admins bypass all checks. For non-admins the policy is: + + - file-* → validated via has_access_to_file + - user-memory-* → must match user's own memory collection + - web-search-* → ephemeral per-query collections, always allowed + - knowledge-bases → always denied (system meta-collection) + - everything else → if the name matches a knowledge base, validated + via Knowledges.check_access_by_user_id; if no + such KB exists, the name is treated as an + ephemeral/legacy collection and allowed + """ + if user.role == 'admin': + return collection_names + + validated = set() + for name in collection_names: + if name == 'knowledge-bases': + # System meta-collection — never exposed to non-admins. + continue + elif name.startswith('file-'): + file_id = name[len('file-') :] + if await has_access_to_file(file_id=file_id, access_type=access_type, user=user): + validated.add(name) + elif name.startswith('user-memory-'): + if name == f'user-memory-{user.id}': + validated.add(name) + elif name.startswith('web-search-'): + # Ephemeral collections created by process_web_search — safe + # to allow because they contain only transient web-search + # results scoped to the requesting user's session. + validated.add(name) + else: + # May be a knowledge-base ID or a legacy/ephemeral collection. + # If it IS a KB, enforce access control. If no such KB + # exists, treat it as a non-sensitive collection (e.g. legacy + # model knowledge, process_text SHA256 collections) and allow. + if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type): + validated.add(name) + elif not await Knowledges.get_knowledge_by_id(name): + # Not a KB at all — legacy/ephemeral collection, allow + validated.add(name) + return validated + + async def get_sources_from_items( request, items, @@ -1138,9 +1198,18 @@ async def get_sources_from_items( log.debug(f'skipping {item} as it has already been extracted') continue + # Filter out collections the user cannot read + if user: + collection_names = await filter_accessible_collections(collection_names, user) + if not collection_names: + log.debug(f'access denied for all collections in item {item}') + continue + try: if full_context: - query_result = get_all_items_from_collections(collection_names) + # Sync helper makes blocking VECTOR_DB_CLIENT calls; + # offload so the async caller's event loop stays free. + query_result = await asyncio.to_thread(get_all_items_from_collections, collection_names) else: query_result = await query_collection( request, diff --git a/backend/open_webui/retrieval/vector/async_client.py b/backend/open_webui/retrieval/vector/async_client.py new file mode 100644 index 0000000000..0bea6696a9 --- /dev/null +++ b/backend/open_webui/retrieval/vector/async_client.py @@ -0,0 +1,129 @@ +""" +Async facade over the synchronous VECTOR_DB_CLIENT. + +The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant, +Milvus, OpenSearch, Pinecone, Weaviate, …) all expose a uniformly +synchronous API. Each method performs blocking network or disk I/O — and +some, like `insert`/`upsert`, can run for several seconds. + +When such a sync method is awaited from an async route handler, it blocks +the event loop for its entire duration, freezing every other in-flight +HTTP request, websocket message and background task. + +This module wraps the sync client in an `AsyncVectorDBClient` that +transparently dispatches each call to a worker thread via +`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)` +in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive. + +The original `VECTOR_DB_CLIENT` is unchanged, so callers already running +inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not +affected. + +Thread-safety expectations +-------------------------- +Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread +rather than the event-loop thread, and many can run concurrently. The +sync client (and its underlying backend driver) is therefore expected +to be safe for concurrent use across threads, which is the standard +contract for the bundled drivers (chroma, pgvector via SQLAlchemy +pool, qdrant-client, opensearch-py, …). This is *not* a new exposure +introduced by this facade — `save_docs_to_vector_db` already called +the sync client from `run_in_threadpool`, so concurrent threaded +access has always been a requirement of the codebase. Adding a global +serialization lock here would defeat the responsiveness this facade +exists to provide; any backend that genuinely cannot tolerate +concurrent access should grow its own internal serialization. + +API surface +----------- +Method signatures mirror `VectorDBBase` exactly. This is deliberate: +permissive `*args/**kwargs` forwarding hides typos at the call site +(an earlier revision of this file shipped that, and a `metadata=` +typo silently broke an entire endpoint until explicit signatures +surfaced it). Callers that need a backend-specific parameter not on +`VectorDBBase` should reach for the `.sync` escape hatch and wrap +their own `asyncio.to_thread`, e.g. :: + + await asyncio.to_thread( + ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op, + collection_name, special_kwarg=value, + ) +""" + +from __future__ import annotations + +import asyncio +from typing import Dict, List, Optional, Union + +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.main import ( + GetResult, + SearchResult, + VectorDBBase, + VectorItem, +) + + +class AsyncVectorDBClient: + """Awaitable mirror of `VectorDBBase` that off-loads each call to a thread. + + Method signatures mirror `VectorDBBase` exactly so static analysis + catches bad kwargs at the call site instead of letting them surface + deep inside the worker thread (where the resulting ``TypeError`` is + typically swallowed by surrounding ``try/except``). + """ + + def __init__(self, sync_client: VectorDBBase) -> None: + self._sync = sync_client + + @property + def sync(self) -> VectorDBBase: + """Escape hatch for code that must call the sync client directly + (e.g. already inside a worker thread).""" + return self._sync + + async def has_collection(self, collection_name: str) -> bool: + return await asyncio.to_thread(self._sync.has_collection, collection_name) + + async def delete_collection(self, collection_name: str) -> None: + return await asyncio.to_thread(self._sync.delete_collection, collection_name) + + async def insert(self, collection_name: str, items: List[VectorItem]) -> None: + return await asyncio.to_thread(self._sync.insert, collection_name, items) + + async def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + return await asyncio.to_thread(self._sync.upsert, collection_name, items) + + async def search( + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[Dict] = None, + limit: int = 10, + ) -> Optional[SearchResult]: + return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit) + + async def query( + self, + collection_name: str, + filter: Dict, + limit: Optional[int] = None, + ) -> Optional[GetResult]: + return await asyncio.to_thread(self._sync.query, collection_name, filter, limit) + + async def get(self, collection_name: str) -> Optional[GetResult]: + return await asyncio.to_thread(self._sync.get, collection_name) + + async def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter) + + async def reset(self) -> None: + return await asyncio.to_thread(self._sync.reset) + + +ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT) diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 4775ff21f4..90e65b9ad0 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -34,6 +34,7 @@ from open_webui.retrieval.vector.main import ( SearchResult, GetResult, ) +from open_webui.utils.misc import sanitize_text_for_db from open_webui.config import ( PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH, @@ -289,7 +290,9 @@ class PgvectorClient(VectorDBBase): vector = self.adjust_vector_length(item['vector']) # Use raw SQL for BYTEA/pgcrypto # Ensure metadata is converted to its JSON text representation - json_metadata = json.dumps(item['metadata']) + # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store + json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" INSERT INTO document_chunk @@ -305,7 +308,7 @@ class PgvectorClient(VectorDBBase): 'id': item['id'], 'vector': vector, 'collection_name': collection_name, - 'text': item['text'], + 'text': item_text, 'metadata_text': json_metadata, 'key': PGVECTOR_PGCRYPTO_KEY, }, @@ -338,7 +341,9 @@ class PgvectorClient(VectorDBBase): if PGVECTOR_PGCRYPTO: for item in items: vector = self.adjust_vector_length(item['vector']) - json_metadata = json.dumps(item['metadata']) + # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store + json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" INSERT INTO document_chunk @@ -358,7 +363,7 @@ class PgvectorClient(VectorDBBase): 'id': item['id'], 'vector': vector, 'collection_name': collection_name, - 'text': item['text'], + 'text': item_text, 'metadata_text': json_metadata, 'key': PGVECTOR_PGCRYPTO_KEY, }, diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index b2e2fed762..4915b024c3 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -1,5 +1,7 @@ from datetime import datetime +from open_webui.utils.misc import sanitize_text_for_db + KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures'] @@ -12,7 +14,8 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: def process_metadata( metadata: dict[str, any], ) -> dict[str, any]: - # Removes large fields and converts non-serializable types (datetime, list, dict) to strings. + # Removes large fields, converts non-serializable types (datetime, list, dict) to strings, + # and sanitizes strings for database storage (strips null bytes and invalid surrogates). result = {} for key, value in metadata.items(): # Skip large fields @@ -20,7 +23,7 @@ def process_metadata( continue # Convert non-serializable fields to strings if isinstance(value, (datetime, list, dict)): - result[key] = str(value) + result[key] = sanitize_text_for_db(str(value)) else: - result[key] = value + result[key] = sanitize_text_for_db(value) return result diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index 4bb23e3797..4af302c0de 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -1,6 +1,7 @@ import logging from typing import Optional, List +import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results log = logging.getLogger(__name__) @@ -14,23 +15,38 @@ def search_firecrawl( filter_list: Optional[List[str]] = None, ) -> List[SearchResult]: try: - from firecrawl import FirecrawlApp + url = firecrawl_url.rstrip('/') + response = requests.post( + f'{url}/v1/search', + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {firecrawl_api_key}', + }, + json={ + 'query': query, + 'limit': count, + 'timeout': count * 3000, + }, + timeout=count * 3 + 10, + ) + response.raise_for_status() + data = response.json().get('data', {}) - firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url) - response = firecrawl.search(query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3) - results = response.web - if filter_list: - results = get_filtered_results(results, filter_list) results = [ SearchResult( - link=result.url, - title=result.title, - snippet=result.description, + link=r.get('url', ''), + title=r.get('title', ''), + snippet=r.get('description', ''), ) - for result in results[:count] + for r in data.get('web', []) ] - log.info(f'External search results: {results}') + + if filter_list: + results = get_filtered_results(results, filter_list) + + results = results[:count] + log.info(f'FireCrawl search results: {results}') return results except Exception as e: - log.error(f'Error in External search: {e}') + log.error(f'Error in FireCrawl search: {e}') return [] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index cc520ffe63..cfe0f71b85 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -192,27 +192,6 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): proxy: Optional[Dict[str, str]] = None, params: Optional[Dict] = None, ): - """Concurrent document loader for FireCrawl operations. - - Executes multiple FireCrawlLoader instances concurrently using thread pooling - to improve bulk processing efficiency. - Args: - web_paths: List of URLs/paths to process. - verify_ssl: If True, verify SSL certificates. - trust_env: If True, use proxy settings from environment variables. - requests_per_second: Number of requests per second to limit to. - continue_on_failure (bool): If True, continue loading other URLs on failure. - api_key: API key for FireCrawl service. Defaults to None - (uses FIRE_CRAWL_API_KEY environment variable if not provided). - api_url: Base URL for FireCrawl API. Defaults to official API endpoint. - mode: Operation mode selection: - - 'crawl': Website crawling mode - - 'scrape': Direct page scraping (default) - - 'map': Site map generation - proxy: Proxy override settings for the FireCrawl API. - params: The parameters to pass to the Firecrawl API. - For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape - """ proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: env_proxies = urllib.request.getproxies() @@ -229,44 +208,43 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): self.trust_env = trust_env self.continue_on_failure = continue_on_failure self.api_key = api_key - self.api_url = api_url + self.api_url = (api_url or 'https://api.firecrawl.dev').rstrip('/') self.timeout = timeout self.mode = mode self.params = params or {} def lazy_load(self) -> Iterator[Document]: - """Load documents using FireCrawl batch_scrape.""" - log.debug( - 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', - len(self.web_paths), - self.mode, - self.params, - ) try: - from firecrawl import FirecrawlApp + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.api_key}', + } - firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) - result = firecrawl.batch_scrape( - self.web_paths, - formats=['markdown'], - skip_tls_verification=not self.verify_ssl, - ignore_invalid_urls=True, - remove_base64_images=True, - max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, - **self.params, - ) + for url in self.web_paths: + payload = { + 'url': url, + 'formats': ['markdown'], + **self.params, + } + if self.timeout: + payload['timeout'] = self.timeout * 1000 - if result.status != 'completed': - raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') - - for data in result.data: - metadata = data.metadata or {} - yield Document( - page_content=data.markdown or '', - metadata={'source': metadata.url or metadata.source_url or ''}, + response = requests.post( + f'{self.api_url}/v1/scrape', + headers=headers, + json=payload, + timeout=self.timeout or 60, + verify=self.verify_ssl, ) + response.raise_for_status() + data = response.json().get('data', {}) + metadata = data.get('metadata', {}) + source = metadata.get('url') or metadata.get('sourceURL') or url + yield Document( + page_content=data.get('markdown', ''), + metadata={'source': source}, + ) except Exception as e: if self.continue_on_failure: log.exception(f'Error extracting content from URLs: {e}') @@ -274,38 +252,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): raise e async def alazy_load(self): - """Async version of lazy_load.""" - log.debug( - 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', - len(self.web_paths), - self.mode, - self.params, - ) try: - from firecrawl import FirecrawlApp - - firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) - result = firecrawl.batch_scrape( - self.web_paths, - formats=['markdown'], - skip_tls_verification=not self.verify_ssl, - ignore_invalid_urls=True, - remove_base64_images=True, - max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, - **self.params, - ) - - if result.status != 'completed': - raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') - - for data in result.data: - metadata = data.metadata or {} - yield Document( - page_content=data.markdown or '', - metadata={'source': metadata.url or metadata.source_url or ''}, - ) - + docs = await run_in_threadpool(lambda: list(self.lazy_load())) + for doc in docs: + yield doc except Exception as e: if self.continue_on_failure: log.exception(f'Error extracting content from URLs: {e}') diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 69744e7219..42327059e6 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -5,7 +5,6 @@ import os import uuid import html import base64 -from functools import lru_cache from pydub import AudioSegment from pydub.silence import split_on_silence from concurrent.futures import ThreadPoolExecutor @@ -54,6 +53,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + BYPASS_PYDUB_PREPROCESSING, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, ) @@ -420,7 +420,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): elif request.app.state.config.TTS_ENGINE == 'elevenlabs': voice_id = payload.get('voice', '') - if voice_id not in get_available_voices(request): + if voice_id not in await get_available_voices(request): raise HTTPException( status_code=400, detail='Invalid voice id', @@ -974,7 +974,10 @@ def transcription_handler(request, file_path, metadata, user=None): # Read and encode audio file as base64 with open(audio_file_to_use, 'rb') as audio_file: - audio_base64 = base64.b64encode(audio_file.read()).decode('utf-8') + audio_base64 = { + 'data': base64.b64encode(audio_file.read()).decode('utf-8'), + 'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'), + } # Prepare chat completions request url = f'{api_base_url}/chat/completions' @@ -1098,24 +1101,28 @@ def transcription_handler(request, file_path, metadata, user=None): def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None): log.info(f'transcribe: {file_path} {metadata}') - if is_audio_conversion_required(file_path): - file_path = convert_audio_to_mp3(file_path) + if BYPASS_PYDUB_PREPROCESSING: + log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)') + chunk_paths = [file_path] + else: + if is_audio_conversion_required(file_path): + file_path = convert_audio_to_mp3(file_path) - try: - file_path = compress_audio(file_path) - except Exception as e: - log.exception(e) + try: + file_path = compress_audio(file_path) + except Exception as e: + log.exception(e) - # Always produce a list of chunk paths (could be one entry if small) - try: - chunk_paths = split_audio(file_path, MAX_FILE_SIZE) - print(f'Chunk paths: {chunk_paths}') - except Exception as e: - log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), - ) + # Always produce a list of chunk paths (could be one entry if small) + try: + chunk_paths = split_audio(file_path, MAX_FILE_SIZE) + print(f'Chunk paths: {chunk_paths}') + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) results = [] try: @@ -1287,40 +1294,53 @@ async def transcription( ) -def get_available_models(request: Request) -> list[dict]: +async def get_available_models(request: Request) -> list[dict]: available_models = [] if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): - try: - response = requests.get( - f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models', - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - data = response.json() - available_models = data.get('models', []) - except Exception as e: - log.error(f'Error fetching models from custom endpoint: {str(e)}') - available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + try: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models', + ) as response: + response.raise_for_status() + data = await response.json() + available_models = data.get('models', []) + except Exception as e: + log.debug(f'/audio/models not available, trying /models fallback: {str(e)}') + # Fallback to standard OpenAI-compatible /models endpoint + # (used by KokoroTTS and similar custom TTS servers) + try: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/models', + ) as response: + response.raise_for_status() + data = await response.json() + # OpenAI /models returns {"data": [...]}, /audio/models returns {"models": [...]} + available_models = data.get('data', data.get('models', [])) + except Exception as e2: + log.error(f'Error fetching models from custom endpoint: {str(e2)}') + available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] else: available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: - response = requests.get( - f'{ELEVENLABS_API_BASE_URL}/v1/models', - headers={ - 'xi-api-key': request.app.state.config.TTS_API_KEY, - 'Content-Type': 'application/json', - }, - timeout=5, - ) - response.raise_for_status() - models = response.json() - - available_models = [{'name': model['name'], 'id': model['model_id']} for model in models] - except requests.RequestException as e: - log.error(f'Error fetching voices: {str(e)}') + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{ELEVENLABS_API_BASE_URL}/v1/models', + headers={ + 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'Content-Type': 'application/json', + }, + ) as response: + response.raise_for_status() + models = await response.json() + available_models = [{'name': model['name'], 'id': model['model_id']} for model in models] + except Exception as e: + log.error(f'Error fetching models: {str(e)}') elif request.app.state.config.TTS_ENGINE == 'mistral': available_models = [{'id': 'mistral-tts-latest'}] return available_models @@ -1328,24 +1348,25 @@ def get_available_models(request: Request) -> list[dict]: @router.get('/models') async def get_models(request: Request, user=Depends(get_verified_user)): - return {'models': get_available_models(request)} + return {'models': await get_available_models(request)} -def get_available_voices(request) -> dict: +async def get_available_voices(request) -> dict: """Returns {voice_id: voice_name} dict""" available_voices = {} if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): try: - response = requests.get( - f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices', - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - data = response.json() - voices_list = data.get('voices', []) - available_voices = {voice['id']: voice['name'] for voice in voices_list} + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices', + ) as response: + response.raise_for_status() + data = await response.json() + voices_list = data.get('voices', []) + available_voices = {voice['id']: voice['name'] for voice in voices_list} except Exception as e: log.error(f'Error fetching voices from custom endpoint: {str(e)}') available_voices = { @@ -1367,7 +1388,7 @@ def get_available_voices(request) -> dict: } elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: - available_voices = get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY) + available_voices = await get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY) except Exception: # Avoided @lru_cache with exception pass @@ -1378,13 +1399,15 @@ def get_available_voices(request) -> dict: url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list' headers = {'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY} - response = requests.get(url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) - response.raise_for_status() - voices = response.json() + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get(url, headers=headers) as response: + response.raise_for_status() + voices = await response.json() - for voice in voices: - available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})' - except requests.RequestException as e: + for voice in voices: + available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})' + except Exception as e: log.error(f'Error fetching voices: {str(e)}') elif request.app.state.config.TTS_ENGINE == 'mistral': api_key = request.app.state.config.TTS_MISTRAL_API_KEY @@ -1392,29 +1415,29 @@ def get_available_voices(request) -> dict: if api_key: try: - response = requests.get( - f'{api_base_url}/audio/voices', - headers={ - 'Authorization': f'Bearer {api_key}', - }, - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - voices_data = response.json() + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{api_base_url}/audio/voices', + headers={ + 'Authorization': f'Bearer {api_key}', + }, + ) as response: + response.raise_for_status() + voices_data = await response.json() - for voice in voices_data: - voice_id = voice.get('voice_id', voice.get('id', '')) - voice_name = voice.get('name', voice_id) - if voice_id: - available_voices[voice_id] = voice_name - except requests.RequestException as e: + for voice in voices_data: + voice_id = voice.get('voice_id', voice.get('id', '')) + voice_name = voice.get('name', voice_id) + if voice_id: + available_voices[voice_id] = voice_name + except Exception as e: log.error(f'Error fetching Mistral voices: {str(e)}') return available_voices -@lru_cache -def get_elevenlabs_voices(api_key: str) -> dict: +async def get_elevenlabs_voices(api_key: str) -> dict: """ Note, set the following in your .env file to use Elevenlabs: AUDIO_TTS_ENGINE=elevenlabs @@ -1425,22 +1448,22 @@ def get_elevenlabs_voices(api_key: str) -> dict: try: # TODO: Add retries - response = requests.get( - f'{ELEVENLABS_API_BASE_URL}/v1/voices', - headers={ - 'xi-api-key': api_key, - 'Content-Type': 'application/json', - }, - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - voices_data = response.json() + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{ELEVENLABS_API_BASE_URL}/v1/voices', + headers={ + 'xi-api-key': api_key, + 'Content-Type': 'application/json', + }, + ) as response: + response.raise_for_status() + voices_data = await response.json() - voices = {} - for voice in voices_data.get('voices', []): - voices[voice['voice_id']] = voice['name'] - except requests.RequestException as e: - # Avoid @lru_cache with exception + voices = {} + for voice in voices_data.get('voices', []): + voices[voice['voice_id']] = voice['name'] + except Exception as e: log.error(f'Error fetching voices: {str(e)}') raise RuntimeError(f'Error fetching voices: {str(e)}') @@ -1449,4 +1472,4 @@ def get_elevenlabs_voices(api_key: str) -> dict: @router.get('/voices') async def get_voices(request: Request, user=Depends(get_verified_user)): - return {'voices': [{'id': k, 'name': v} for k, v in get_available_voices(request).items()]} + return {'voices': [{'id': k, 'name': v} for k, v in (await get_available_voices(request)).items()]} diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index cfc05160c1..651e123b64 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1131,7 +1131,7 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user for key in required_fields: value = getattr(form_data, key) if not value: - raise HTTPException(400, detail=f'Required field {key} is empty') + raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key)) request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_HOST = form_data.host @@ -1260,7 +1260,7 @@ async def token_exchange( if provider not in OAUTH_PROVIDERS: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Provider '{provider}' is not configured", + detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider), ) # Get the OAuth client for this provider oauth_manager = request.app.state.oauth_manager @@ -1268,7 +1268,7 @@ async def token_exchange( if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"OAuth client for '{provider}' not found", + detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider), ) # Validate the token by calling the userinfo endpoint diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 9c532a8915..d68bd8e2c6 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -74,7 +74,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create: if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f'Automation limit reached ({max_count})', + detail=ERROR_MESSAGES.AUTOMATION_LIMIT_EXCEEDED(max_count), ) # Min interval (create + update) @@ -86,7 +86,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create: if interval is not None and interval < min_interval: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Schedule too frequent. Minimum interval is {min_interval} seconds.', + detail=ERROR_MESSAGES.AUTOMATION_TOO_FREQUENT(min_interval), ) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index b6eee93eac..a771b95920 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -140,7 +140,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No if not request.app.state.config.ENABLE_CHANNELS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail='Channels are not enabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'), ) if user: @@ -1791,7 +1791,7 @@ async def post_webhook_message( if not webhook: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail='Invalid webhook URL', + detail=ERROR_MESSAGES.INVALID_URL, ) channel = await Channels.get_channel_by_id(webhook.channel_id, db=db) @@ -1809,7 +1809,7 @@ async def post_webhook_message( if not message: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Failed to create message', + detail=ERROR_MESSAGES.DEFAULT('Failed to create message'), ) # Update last_used_at diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 979b9388cf..02f9a74662 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,6 +1,7 @@ import json import logging from typing import Optional +from uuid import uuid4 from sqlalchemy.ext.asyncio import AsyncSession import asyncio from fastapi.responses import StreamingResponse @@ -16,13 +17,14 @@ from open_webui.models.chats import ( ChatResponse, Chats, ChatTitleIdResponse, - SharedChatResponse, ChatStatsExport, AggregateChatStats, ChatBody, ChatHistoryStats, MessageStats, ) +from open_webui.models.shared_chats import SharedChats, SharedChatResponse +from open_webui.models.access_grants import AccessGrants from open_webui.models.tags import TagModel, Tags from open_webui.models.folders import Folders from open_webui.internal.db import get_async_session @@ -34,7 +36,7 @@ from pydantic import BaseModel from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants log = logging.getLogger(__name__) @@ -557,7 +559,7 @@ async def create_new_chat( db: AsyncSession = Depends(get_async_session), ): try: - chat = await Chats.insert_new_chat(user.id, form_data, db=db) + chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) return ChatResponse(**chat.model_dump()) except Exception as e: log.exception(e) @@ -806,7 +808,7 @@ async def get_shared_session_user_chat_list( if direction: filter['direction'] = direction - return await Chats.get_shared_chat_list_by_user_id( + return await SharedChats.get_by_user_id( user.id, filter=filter, skip=skip, @@ -827,17 +829,32 @@ async def get_shared_chat_by_id( if user.role == 'pending': raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS): - chat = await Chats.get_chat_by_share_id(share_id, db=db) - elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) - - if chat: - return ChatResponse(**chat.model_dump()) - else: + chat = await Chats.get_chat_by_share_id(share_id, db=db) + + if not chat: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + # Look up the original chat_id to check access grants + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return ChatResponse(**chat.model_dump()) + ############################ # GetChatsByTags @@ -877,11 +894,25 @@ async def get_user_chat_list_by_tag_name( async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + # Check if user has access via access grants (shared_chat grants) + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) + else: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) + if chat: return ChatResponse(**chat.model_dump()) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1157,39 +1188,58 @@ async def clone_shared_chat_by_id( else: chat = await Chats.get_chat_by_share_id(id, db=db) - if chat: - updated_chat = { - **chat.chat, - 'originalChatId': chat.id, - 'branchPointMessageId': chat.chat['history']['currentId'], - 'title': f'Clone of {chat.title}', - } - - chats = await Chats.import_chats( - user.id, - [ - ChatImportForm( - **{ - 'chat': updated_chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - } - ) - ], - db=db, + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, ) - if chats: - chat = chats[0] - return ChatResponse(**chat.model_dump()) - else: + # Enforce access grants + shared = await SharedChats.get_by_id(id, db=db) + if shared and user.role != 'admin': + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(), + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + + updated_chat = { + **chat.chat, + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': f'Clone of {chat.title}', + } + + chats = await Chats.import_chats( + user.id, + [ + ChatImportForm( + **{ + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, + } + ) + ], + db=db, + ) + + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) ############################ @@ -1240,16 +1290,28 @@ async def share_chat_by_id( if chat: if chat.share_id: - shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db) - return ChatResponse(**shared_chat.model_dump()) + # Re-snapshot existing share + shared = await SharedChats.update(chat.share_id, db=db) + if shared: + # Re-fetch the original chat to return + chat = await Chats.get_chat_by_id(id, db=db) + return ChatResponse(**chat.model_dump()) - shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db) - if not shared_chat: + # Create new share + shared = await SharedChats.create(id, user.id, db=db) + if not shared: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(), ) - return ChatResponse(**shared_chat.model_dump()) + # Set share_id on the original chat + chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -1259,7 +1321,7 @@ async def share_chat_by_id( ############################ -# DeletedSharedChatById +# DeleteSharedChatById ############################ @@ -1272,10 +1334,13 @@ async def delete_shared_chat_by_id( if not chat.share_id: return False - result = await Chats.delete_shared_chat_by_chat_id(id, db=db) - update_result = await Chats.update_chat_share_id_by_id(id, None, db=db) + await SharedChats.delete_by_chat_id(id, db=db) + await Chats.update_chat_share_id_by_id(id, None, db=db) - return result and update_result != None + # Revoke all access grants for this shared chat + await AccessGrants.set_access_grants('shared_chat', id, [], db=db) + + return True else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1283,6 +1348,85 @@ async def delete_shared_chat_by_id( ) +############################ +# UpdateSharedChatAccessById +############################ + + +class ChatAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse]) +async def update_shared_chat_access_by_id( + request: Request, + id: str, + form_data: ChatAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_chats', + ) + + await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) + + return ChatResponse(**chat.model_dump()) + + +############################ +# GetSharedChatAccessById +############################ + + +@router.get('/shared/{id}/access', response_model=list) +async def get_shared_chat_access_by_id( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db) + return [ + { + 'id': g.id, + 'principal_type': g.principal_type, + 'principal_id': g.principal_id, + 'permission': g.permission, + } + for g in grants + ] + + ############################ # UpdateChatFolderIdById ############################ diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 041c4ad935..7c54c09039 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -8,7 +8,7 @@ from typing import Optional from open_webui.env import AIOHTTP_CLIENT_TIMEOUT from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.config import get_config, save_config +from open_webui.config import get_config, save_config, async_save_config from open_webui.config import BannerModel from open_webui.utils.tools import ( @@ -27,6 +27,7 @@ from open_webui.utils.oauth import ( get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, + resolve_oauth_client_info, OAuthClientInformationFull, ) from mcp.shared.auth import OAuthMetadata @@ -49,7 +50,7 @@ class ImportConfigForm(BaseModel): @router.post('/import', response_model=dict) async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)): - save_config(form_data.config) + await async_save_config(form_data.config) return get_config() @@ -203,9 +204,7 @@ async def set_tool_servers_config( if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id: try: - oauth_client_info = connection.get('info', {}).get('oauth_client_info', '') - oauth_client_info = decrypt_data(oauth_client_info) - + oauth_client_info = resolve_oauth_client_info(connection) request.app.state.oauth_client_manager.add_client( f'{server_type}:{server_id}', OAuthClientInformationFull(**oauth_client_info), diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 66d7539278..7ca1c2e73f 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -25,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import get_async_session, get_async_db_context from open_webui.constants import ERROR_MESSAGES -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.models.channels import Channels from open_webui.models.users import Users @@ -124,7 +124,7 @@ async def process_uploaded_file( stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) if strict_match_mime_type(stt_supported_content_types, content_type): - file_path_processed = Storage.get_file(file_path) + file_path_processed = await asyncio.to_thread(Storage.get_file, file_path) result = transcribe(request, file_path_processed, file_metadata, user) await process_file( @@ -242,7 +242,8 @@ async def upload_file_handler( id = str(uuid.uuid4()) name = filename filename = f'{id}_{filename}' - contents, file_path = Storage.upload_file( + contents, file_path = await asyncio.to_thread( + Storage.upload_file, file.file, filename, { @@ -406,8 +407,8 @@ async def delete_all_files(user=Depends(get_admin_user), db: AsyncSession = Depe result = await Files.delete_all_files(db=db) if result: try: - Storage.delete_all_files() - VECTOR_DB_CLIENT.reset() + await asyncio.to_thread(Storage.delete_all_files) + await ASYNC_VECTOR_DB_CLIENT.reset() except Exception as e: log.exception(e) log.error('Error deleting files') @@ -577,7 +578,7 @@ async def update_file_data_content_by_id( for knowledge in knowledges: try: # Remove old embeddings for this file from the KB collection - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) # Re-add from the now-updated file-{file_id} collection await process_file( request, @@ -618,7 +619,7 @@ async def get_file_content_by_id( if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -685,7 +686,7 @@ async def get_html_file_content_by_id( if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -734,7 +735,7 @@ async def get_file_content_by_id( headers = {'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"} if file_path: - file_path = Storage.get_file(file_path) + file_path = await asyncio.to_thread(Storage.get_file, file_path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -789,17 +790,17 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncS await Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db) # Clean KB embeddings (same logic as /knowledge/{id}/file/remove) try: - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) if file.hash: - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) except Exception as e: log.debug(f'KB embedding cleanup for {knowledge.id}: {e}') result = await Files.delete_file_by_id(id, db=db) if result: try: - Storage.delete_file(file.path) - VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') + await asyncio.to_thread(Storage.delete_file, file.path) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') except Exception as e: log.exception(e) log.error('Error deleting files') diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index de09aa05a1..1d0f0342d2 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -128,7 +128,7 @@ async def load_function_from_url(request: Request, form_data: LoadUrlForm, user= 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f'Error importing function: {e}') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e)) ############################ diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 0d534db7f6..7ef4938b2e 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -10,7 +10,8 @@ from pathlib import Path from typing import Optional from urllib.parse import quote -import requests +import aiohttp + from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi.responses import FileResponse @@ -21,7 +22,8 @@ from open_webui.config import ( ) from open_webui.constants import ERROR_MESSAGES from open_webui.retrieval.web.utils import validate_url -from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS +from open_webui.utils.session_pool import get_session from open_webui.models.chats import Chats from open_webui.routers.files import upload_file_handler, get_file_content_by_id @@ -50,32 +52,36 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) router = APIRouter() -def set_image_model(request: Request, model: str): +async def set_image_model(request: Request, model: str): log.info(f'Setting image model to {model}') request.app.state.config.IMAGE_GENERATION_MODEL = model if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: api_auth = get_automatic1111_api_auth(request) try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': api_auth}, - ) - options = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + options = await r.json() if model != options['sd_model_checkpoint']: options['sd_model_checkpoint'] = model - r = requests.post( + async with session.post( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', json=options, headers={'authorization': api_auth}, - ) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() except Exception as e: log.debug(f'{e}') return request.app.state.config.IMAGE_GENERATION_MODEL -def get_image_model(request): +async def get_image_model(request): if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': return ( request.app.state.config.IMAGE_GENERATION_MODEL @@ -97,14 +103,15 @@ def get_image_model(request): or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - options = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + options = await r.json() return options['sd_model_checkpoint'] except Exception as e: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -198,7 +205,7 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends( request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE - set_image_model(request, form_data.IMAGE_GENERATION_MODEL) + await set_image_model(request, form_data.IMAGE_GENERATION_MODEL) if form_data.IMAGE_SIZE == 'auto' and not re.match( IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL ): @@ -313,28 +320,30 @@ def get_automatic1111_api_auth(request: Request): async def verify_url(request: Request, user=Depends(get_admin_user)): if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111': try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - r.raise_for_status() - return True + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return True except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': headers = None if request.app.state.config.COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, - ) - r.raise_for_status() - return True + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return True except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) else: return True @@ -357,11 +366,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)): elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': # TODO - get models from comfyui headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, - ) - info = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + info = await r.json() workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW) model_node_id = None @@ -399,11 +410,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)): request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - models = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + models = await r.json() return list( map( lambda model: {'id': model['title'], 'name': model['model_name']}, @@ -411,7 +424,6 @@ async def get_models(request: Request, user=Depends(get_verified_user)): ) ) except Exception as e: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -427,21 +439,22 @@ class CreateImageForm(BaseModel): GenerateImageForm = CreateImageForm # Alias for backward compatibility -def get_image_data(data: str, headers=None): +async def get_image_data(data: str, headers=None): try: if data.startswith('http://') or data.startswith('https://'): - if headers: - r = requests.get(data, headers=headers) - else: - r = requests.get(data) - - r.raise_for_status() - if r.headers['content-type'].split('/')[0] == 'image': - mime_type = r.headers['content-type'] - return r.content, mime_type - else: - log.error('Url does not point to an image.') - return None + session = await get_session() + async with session.get( + data, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + content_type = r.headers.get('content-type', '') + if content_type.split('/')[0] == 'image': + return await r.read(), content_type + else: + log.error('Url does not point to an image.') + return None, None else: if ',' in data: header, encoded = data.split(',', 1) @@ -531,9 +544,8 @@ async def image_generations( metadata = metadata or {} - model = get_image_model(request) + model = await get_image_model(request) - r = None try: if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': headers = { @@ -552,7 +564,11 @@ async def image_generations( 'model': model, 'prompt': form_data.prompt, 'n': form_data.n, - 'size': (form_data.size if form_data.size else request.app.state.config.IMAGE_SIZE), + **( + {'size': form_data.size or request.app.state.config.IMAGE_SIZE} + if (form_data.size or request.app.state.config.IMAGE_SIZE) + else {} + ), **( {} if re.match( @@ -568,27 +584,26 @@ async def image_generations( ), } - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=url, json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['data']: if image_url := image.get('url', None): - image_data, content_type = get_image_data( + image_data, content_type = await get_image_data( image_url, {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image['b64_json']) + image_data, content_type = await get_image_data(image['b64_json']) _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) @@ -619,29 +634,28 @@ async def image_generations( model = f'{model}:generateContent' data = {'contents': [{'parts': [{'text': form_data.prompt}]}]} - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] if model.endswith(':predict'): for image in res['predictions']: - image_data, content_type = get_image_data(image['bytesBase64Encoded']) + image_data, content_type = await get_image_data(image['bytesBase64Encoded']) _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) elif model.endswith(':generateContent'): for image in res['candidates']: for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): - image_data, content_type = get_image_data(part['inlineData']['data']) + image_data, content_type = await get_image_data(part['inlineData']['data']) _, url = await upload_image( request, image_data, @@ -694,7 +708,7 @@ async def image_generations( if request.app.state.config.COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} - image_data, content_type = get_image_data(image['url'], headers) + image_data, content_type = await get_image_data(image['url'], headers) _, url = await upload_image( request, image_data, @@ -709,7 +723,7 @@ async def image_generations( or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): if form_data.model: - set_image_model(request, form_data.model) + await set_image_model(request, form_data.model) data = { 'prompt': form_data.prompt, @@ -727,21 +741,20 @@ async def image_generations( if request.app.state.config.AUTOMATIC1111_PARAMS: data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS} - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img', json=data, headers={'authorization': get_automatic1111_api_auth(request)}, - ) - - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + res = await r.json() log.debug(f'res: {res}') images = [] for image in res['images']: - image_data, content_type = get_image_data(image) + image_data, content_type = await get_image_data(image) _, url = await upload_image( request, image_data, @@ -753,10 +766,8 @@ async def image_generations( return images except Exception as e: error = e - if r != None: - data = r.json() - if 'error' in data: - error = data['error']['message'] + if isinstance(e, aiohttp.ClientResponseError): + error = e.message raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) @@ -798,11 +809,12 @@ async def image_edits( if data.startswith('http://') or data.startswith('https://'): # Validate URL to prevent SSRF attacks against local/private networks validate_url(data) - r = await asyncio.to_thread(requests.get, data) - r.raise_for_status() + session = await get_session() + async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: + r.raise_for_status() - image_data = base64.b64encode(r.content).decode('utf-8') - return f'data:{r.headers["content-type"]};base64,{image_data}' + image_data = base64.b64encode(await r.read()).decode('utf-8') + return f'data:{r.headers["content-type"]};base64,{image_data}' else: file_id = None @@ -846,7 +858,6 @@ async def image_edits( ), ) - r = None try: if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai': headers = { @@ -883,27 +894,40 @@ async def image_edits( if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}' - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + # Build multipart form data for aiohttp + form = aiohttp.FormData() + for key, value in data.items(): + if isinstance(value, dict): + form.add_field(key, json.dumps(value)) + else: + form.add_field(key, str(value)) + for param_name, (filename, file_obj, content_type_val) in files: + form.add_field( + param_name, + file_obj, + filename=filename, + content_type=content_type_val, + ) + + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}', headers=headers, - files=files, - data=data, - ) - - r.raise_for_status() - res = r.json() + data=form, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['data']: if image_url := image.get('url', None): - image_data, content_type = get_image_data( + image_data, content_type = await get_image_data( image_url, {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image['b64_json']) + image_data, content_type = await get_image_data(image['b64_json']) _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) @@ -940,22 +964,21 @@ async def image_edits( ] ) - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['candidates']: for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): - image_data, content_type = get_image_data(part['inlineData']['data']) + image_data, content_type = await get_image_data(part['inlineData']['data']) _, url = await upload_image( request, image_data, @@ -1035,7 +1058,7 @@ async def image_edits( if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'} - image_data, content_type = get_image_data(image_url, headers) + image_data, content_type = await get_image_data(image_url, headers) _, url = await upload_image( request, image_data, @@ -1048,13 +1071,7 @@ async def image_edits( return images except Exception as e: error = e - if r != None: - data = r.text - try: - data = json.loads(data) - if 'error' in data: - error = data['error']['message'] - except Exception: - error = data + if isinstance(e, aiohttp.ClientResponseError): + error = e.message raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index c6f8ce5ecd..d8d92b2428 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -19,7 +19,7 @@ from open_webui.models.knowledge import ( KnowledgeUserResponse, ) from open_webui.models.files import Files, FileModel, FileMetadataResponse -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.routers.retrieval import ( process_file, ProcessFileForm, @@ -66,7 +66,7 @@ async def embed_knowledge_base_metadata( try: content = f'{name}\n\n{description}' if description else name embedding = await request.app.state.EMBEDDING_FUNCTION(content) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=KNOWLEDGE_BASES_COLLECTION, items=[ { @@ -85,10 +85,10 @@ async def embed_knowledge_base_metadata( return False -def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: +async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: """Remove knowledge base embedding.""" try: - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=KNOWLEDGE_BASES_COLLECTION, ids=[knowledge_base_id], ) @@ -310,8 +310,8 @@ async def reindex_knowledge_files( try: files = await Knowledges.get_files_by_id(knowledge_base.id, db=db) try: - if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): - VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) except Exception as e: log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}') continue # Skip, don't raise @@ -539,10 +539,12 @@ async def update_knowledge_access_by_id( 'sharing.public_knowledge', ) - await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) + knowledge.access_grants = await AccessGrants.set_access_grants( + 'knowledge', id, form_data.access_grants, db=db + ) return KnowledgeFilesResponse( - **(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(), + **knowledge.model_dump(), files=await Knowledges.get_file_metadatas_by_id(id, db=db), ) @@ -732,7 +734,7 @@ async def update_file_from_knowledge_by_id( ) # Remove content from the vector database - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id}) # Add content to the vector database try: @@ -814,11 +816,11 @@ async def remove_file_from_knowledge_by_id( # Remove content from the vector database try: - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={'file_id': form_data.file_id} ) # Remove by file_id first - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={'hash': file.hash} ) # Remove by hash as well in case of duplicates except Exception as e: @@ -826,12 +828,16 @@ async def remove_file_from_knowledge_by_id( log.debug(e) pass - if delete_file: + # Only the file owner or an admin may permanently delete the underlying + # file. Collaborators with KB write access can unlink a file from the + # knowledge base but must not be able to destroy files they do not own, + # as the same file may be referenced by other KBs and chats. + if delete_file and (file.user_id == user.id or user.role == 'admin'): try: # Remove the file's collection from vector database file_collection = f'file-{form_data.file_id}' - if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) except Exception as e: log.debug('This was most likely caused by bypassing embedding processing') log.debug(e) @@ -901,27 +907,18 @@ async def delete_knowledge_by_id( if len(updated_knowledge) != len(knowledge_list): log.info(f'Updating model {model.id} to remove knowledge base {id}') model.meta.knowledge = updated_knowledge - # Create a ModelForm for the update - model_form = ModelForm( - id=model.id, - name=model.name, - base_model_id=model.base_model_id, - meta=model.meta, - params=model.params, - access_grants=model.access_grants, - is_active=model.is_active, - ) + model_form = ModelForm(**model.model_dump()) await Models.update_model_by_id(model.id, model_form, db=db) # Clean up vector DB try: - VECTOR_DB_CLIENT.delete_collection(collection_name=id) + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) except Exception as e: log.debug(e) pass # Remove knowledge base embedding - remove_knowledge_base_metadata_embedding(id) + await remove_knowledge_base_metadata_embedding(id) result = await Knowledges.delete_knowledge_by_id(id=id, db=db) return result @@ -960,7 +957,7 @@ async def reset_knowledge_by_id( ) try: - VECTOR_DB_CLIENT.delete_collection(collection_name=id) + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) except Exception as e: log.debug(e) pass diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 3f6c080ad6..6522118258 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -5,7 +5,7 @@ import asyncio from typing import Optional from open_webui.models.memories import Memories, MemoryModel -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.utils.auth import get_verified_user from open_webui.internal.db import get_async_session from sqlalchemy.ext.asyncio import AsyncSession @@ -85,7 +85,7 @@ async def add_memory( vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -138,12 +138,44 @@ async def query_memory( vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user) - results = VECTOR_DB_CLIENT.search( + results = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=f'user-memory-{user.id}', vectors=[vector], limit=form_data.k, ) + # Filter results by relevance threshold to avoid returning unrelated + # memories. Vector similarity search always returns the top-K nearest + # neighbours even when they are completely irrelevant; applying the + # same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching + # memories are surfaced (distances are normalised to 0→1, higher is + # better). + relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0) + if results and relevance_threshold > 0.0 and results.distances and results.distances[0]: + from open_webui.retrieval.vector.main import SearchResult + + filtered_ids = [] + filtered_docs = [] + filtered_metas = [] + filtered_dists = [] + + for idx, score in enumerate(results.distances[0]): + if score >= relevance_threshold: + if results.ids and results.ids[0]: + filtered_ids.append(results.ids[0][idx]) + if results.documents and results.documents[0]: + filtered_docs.append(results.documents[0][idx]) + if results.metadatas and results.metadatas[0]: + filtered_metas.append(results.metadatas[0][idx]) + filtered_dists.append(score) + + results = SearchResult( + ids=[filtered_ids] if filtered_ids else [[]], + documents=[filtered_docs] if filtered_docs else [[]], + metadatas=[filtered_metas] if filtered_metas else [[]], + distances=[filtered_dists] if filtered_dists else [[]], + ) + return results @@ -175,7 +207,7 @@ async def reset_memory_from_vector_db( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') memories = await Memories.get_memories_by_user_id(user.id) @@ -184,7 +216,7 @@ async def reset_memory_from_vector_db( *[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories] ) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -230,7 +262,7 @@ async def delete_memory_by_user_id( if result: try: - VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') except Exception as e: log.error(e) return True @@ -268,12 +300,12 @@ async def update_memory_by_id( memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content) if memory is None: - raise HTTPException(status_code=404, detail='Memory not found') + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) if form_data.content is not None: vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -318,7 +350,7 @@ async def delete_memory_by_id( result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) if result: - VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return True return False diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index b7d31321ff..3ef0838fcc 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -281,24 +281,71 @@ async def import_models( model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else []) } + # Batch-resolve write permissions in one query instead of + # per-model has_access calls (N+1 avoidance). + existing_model_ids = list(existing_models.keys()) + if user.role != 'admin' and existing_model_ids: + groups = await Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + writable_model_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='model', + resource_ids=existing_model_ids, + permission='write', + user_group_ids=user_group_ids, + db=db, + ) + else: + writable_model_ids = set(existing_model_ids) + for model_data in data: - # Here, you can add logic to validate model_data if needed model_id = model_data.get('id') if model_id and is_valid_model_id(model_id): existing_model = existing_models.get(model_id) if existing_model: + # Enforce ownership/write-access before allowing overwrite + if ( + user.role != 'admin' + and existing_model.user_id != user.id + and model_id not in writable_model_ids + ): + log.warning( + 'import_models: user %s skipped model %s (no write access)', + user.id, + model_id, + ) + continue + # Update existing model model_data['meta'] = model_data.get('meta', {}) model_data['params'] = model_data.get('params', {}) updated_model = ModelForm(**{**existing_model.model_dump(), **model_data}) + # Only filter access_grants when explicitly provided + # in the payload to avoid altering existing ACLs on + # metadata-only imports. + if 'access_grants' in model_data: + updated_model.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + updated_model.access_grants, + 'sharing.public_models', + ) await Models.update_model_by_id(model_id, updated_model, db=db) else: # Insert new model model_data['meta'] = model_data.get('meta', {}) model_data['params'] = model_data.get('params', {}) new_model = ModelForm(**model_data) + new_model.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + new_model.access_grants, + 'sharing.public_models', + ) await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db) return True else: @@ -384,8 +431,12 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes @router.get('/model/profile/image') -async def get_model_profile_image(id: str, user=Depends(get_verified_user)): - model = await Models.get_model_by_id(id) +async def get_model_profile_image( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + model = await Models.get_model_by_id(id, db=db) if model: etag = f'"{model.updated_at}"' if model.updated_at else None diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 61c9fb7d95..4fbdd09993 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -58,6 +58,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -104,6 +105,45 @@ async def get_notes( ] +############################ +# GetPinnedNotes +############################ + + +@router.get('/pinned', response_model=list[NoteItemResponse]) +async def get_pinned_notes( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = await Notes.get_pinned_notes_by_user_id(user.id, 'read', db=db) + if not notes: + return [] + + user_ids = list(set(note.user_id for note in notes)) + users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} + + return [ + NoteUserResponse( + **{ + **note.model_dump(), + 'data': _truncate_note_data(note.data), + 'user': UserResponse(**users[note.user_id].model_dump()), + } + ) + for note in notes + if note.user_id in users + ] + + @router.get('/search', response_model=NoteListResponse) async def search_notes( request: Request, @@ -364,6 +404,46 @@ async def update_note_access_by_id( return await Notes.get_note_by_id(id, db=db) +############################ +# PinNoteById +############################ + + +@router.post('/{id}/pin', response_model=Optional[NoteModel]) +async def pin_note_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + note = await Notes.toggle_note_pinned_by_id(id, db=db) + return note + + ############################ # DeleteNoteById ############################ diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 0c6fe73abf..b957310b58 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -157,7 +157,7 @@ async def send_request( log.error(f'Failed to parse error response: {e}') raise HTTPException( status_code=r.status, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) r.raise_for_status() @@ -184,7 +184,7 @@ async def send_request( except Exception as e: raise HTTPException( status_code=r.status if r else 500, - detail=f'Ollama: {e}' if str(e) else 'Open WebUI: Server Connection Error', + detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: @@ -251,7 +251,7 @@ async def verify_connection(form_data: ConnectionVerificationForm, user=Depends( return data except aiohttp.ClientError as e: log.exception(f'Client error: {str(e)}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) except Exception as e: log.exception(f'Unexpected error: {e}') error_detail = f'Unexpected error: {str(e)}' @@ -428,7 +428,7 @@ async def get_filtered_models(models, user, db=None): @router.get('/api/tags/{url_idx}') async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) models = [] @@ -621,7 +621,7 @@ async def pull_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) @@ -656,7 +656,7 @@ async def push_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: await get_all_models(request, user=user) @@ -699,7 +699,7 @@ async def create_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.debug(f'form_data: {form_data}') url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] @@ -727,7 +727,7 @@ async def copy_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: await get_all_models(request, user=user) @@ -762,7 +762,7 @@ async def delete_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) @@ -797,7 +797,7 @@ async def delete_model( @router.post('/api/show') async def show_model_info(request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) @@ -850,7 +850,7 @@ async def embed( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_batch_embeddings {form_data}') @@ -909,7 +909,7 @@ async def embeddings( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_embeddings {form_data}') @@ -976,7 +976,7 @@ async def generate_completion( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) # Enforce per-model access control await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) @@ -1020,6 +1020,8 @@ class ChatMessage(BaseModel): tool_calls: Optional[list[dict]] = None images: Optional[list[str]] = None + model_config = ConfigDict(extra='allow') + @validator('content', pre=True) @classmethod def check_at_least_one_field(cls, field_value, values, **kwargs): @@ -1067,7 +1069,7 @@ async def generate_chat_completion( bypass_system_prompt: bool = False, ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. @@ -1313,7 +1315,7 @@ async def generate_anthropic_messages( See https://docs.ollama.com/api/anthropic-compatibility """ if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = {**form_data} model_id = payload.get('model', '') @@ -1371,7 +1373,7 @@ async def generate_responses( See https://ollama.com/blog/responses-api """ if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) payload = form_data.model_dump() model_id = form_data.model @@ -1381,29 +1383,9 @@ async def generate_responses( if model_info.base_model_id: payload['model'] = model_info.base_model_id - # Check if user has access to the model - if user.role == 'user': - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} - if not ( - user.id == model_info.user_id - or await AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model_info.id, - permission='read', - user_group_ids=user_group_ids, - ) - ): - raise HTTPException( - status_code=403, - detail='Model not found', - ) + await check_model_access(user, model_info) else: - if user.role != 'admin': - raise HTTPException( - status_code=403, - detail='Model not found', - ) + await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 09db29e6d6..82c844afba 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -8,7 +8,7 @@ from urllib.parse import quote, urlparse import aiohttp from aiocache import cached -import requests + from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -312,19 +312,20 @@ async def speech(request: Request, user=Depends(get_verified_user)): r = None try: - r = requests.post( + session = await get_session() + r = await session.post( url=f'{url}/audio/speech', data=body, headers=headers, cookies=cookies, - stream=True, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() # Save the streaming content to a file with open(file_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): + async for chunk in r.content.iter_chunked(8192): f.write(chunk) with open(file_body_path, 'w') as f: @@ -339,14 +340,14 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = None if r is not None: try: - res = r.json() + res = await r.json() if 'error' in res: detail = f'External: {res["error"]}' except Exception: detail = f'External: {e}' raise HTTPException( - status_code=r.status_code if r else 500, + status_code=r.status if r else 500, detail=detail if detail else 'Open WebUI: Server Connection Error', ) @@ -691,7 +692,7 @@ async def verify_connection( elif is_anthropic_url(url): result = await get_anthropic_models(url, key) if result is None: - raise HTTPException(status_code=500, detail='Failed to connect to Anthropic API') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) if 'error' in result: raise HTTPException(status_code=500, detail=result['error']) return result @@ -718,10 +719,10 @@ async def verify_connection( except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues log.exception(f'Client error: {str(e)}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) except Exception as e: log.exception(f'Unexpected error: {e}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) def get_azure_allowed_params(api_version: str) -> set[str]: @@ -1083,7 +1084,7 @@ async def generate_chat_completion( else: raise HTTPException( status_code=404, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Get the API config for the model @@ -1243,7 +1244,7 @@ async def generate_chat_completion( raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: @@ -1321,7 +1322,7 @@ async def embeddings(request: Request, form_data: dict, user): log.exception(e) raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: @@ -1445,7 +1446,7 @@ async def responses( log.exception(e) raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index a4a75754f0..11901fc5a7 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -324,7 +324,7 @@ async def update_prompt_by_id( if existing_prompt and existing_prompt.id != prompt.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Command '/{form_data.command}' is already in use by another prompt", + detail=ERROR_MESSAGES.COMMAND_TAKEN, ) form_data.access_grants = await filter_allowed_access_grants( @@ -389,7 +389,7 @@ async def update_prompt_metadata( if existing_prompt and existing_prompt.id != prompt.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Command '/{form_data.command}' is already in use", + detail=ERROR_MESSAGES.COMMAND_TAKEN, ) updated_prompt = await Prompts.update_prompt_metadata( @@ -751,7 +751,7 @@ async def get_prompt_diff( if not diff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='One or both history entries not found', + detail=ERROR_MESSAGES.NOT_FOUND, ) return diff diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 1e1beac4fa..ee8a9007fe 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -45,6 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT # Document loaders from open_webui.retrieval.loaders.main import Loader @@ -81,6 +82,7 @@ from open_webui.retrieval.web.yandex import search_yandex from open_webui.retrieval.web.ydc import search_youcom from open_webui.retrieval.utils import ( + filter_accessible_collections, get_content_from_url, get_embedding_function, get_reranking_function, @@ -151,7 +153,7 @@ def get_ef( model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS, ) except Exception as e: - log.debug(f'Error loading SentenceTransformer: {e}') + log.error(f'Error loading SentenceTransformer: {e}') return ef @@ -486,6 +488,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): # Reranking settings 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, + 'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE, 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, @@ -693,6 +696,7 @@ class ConfigForm(BaseModel): # Reranking settings RAG_RERANKING_MODEL: Optional[str] = None RAG_RERANKING_ENGINE: Optional[str] = None + RAG_RERANKING_BATCH_SIZE: Optional[int] = None RAG_EXTERNAL_RERANKER_URL: Optional[str] = None RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None @@ -939,6 +943,12 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT ) + request.app.state.config.RAG_RERANKING_BATCH_SIZE = ( + form_data.RAG_RERANKING_BATCH_SIZE + if form_data.RAG_RERANKING_BATCH_SIZE is not None + else request.app.state.config.RAG_RERANKING_BATCH_SIZE + ) + log.info( f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}' ) @@ -966,6 +976,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend request.app.state.config.RAG_RERANKING_ENGINE, request.app.state.config.RAG_RERANKING_MODEL, request.app.state.rf, + reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE, ) except Exception as e: log.error(f'Error loading reranking model: {e}') @@ -1556,7 +1567,7 @@ async def process_file( try: # /files/{file_id}/data/content/update - VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') except Exception: # Audio file upload pipeline pass @@ -1579,7 +1590,9 @@ async def process_file( # Check if the file has already been processed and save the content # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update - result = VECTOR_DB_CLIENT.query(collection_name=f'file-{file.id}', filter={'file_id': file.id}) + result = await ASYNC_VECTOR_DB_CLIENT.query( + collection_name=f'file-{file.id}', filter={'file_id': file.id} + ) if result is not None and len(result.ids[0]) > 0: docs = [ @@ -1609,7 +1622,7 @@ async def process_file( # Usage: /files/ file_path = file.path if file_path: - file_path = Storage.get_file(file_path) + file_path = await asyncio.to_thread(Storage.get_file, file_path) loader = Loader( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, user=user, @@ -1643,7 +1656,7 @@ async def process_file( MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT, MINERU_PARAMS=request.app.state.config.MINERU_PARAMS, ) - docs = loader.load(file.filename, file.meta.get('content_type'), file_path) + docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path) docs = [ Document( @@ -1698,7 +1711,12 @@ async def process_file( # External embedding API takes time (5-60s+). # Subsequent updates use fresh async sessions. - result = save_docs_to_vector_db( + # NOTE: save_docs_to_vector_db is a sync function that + # calls asyncio.run_coroutine_threadsafe(..., main_loop).result() + # which blocks the calling thread. We MUST run it in a + # worker thread to avoid deadlocking the event loop. + result = await run_in_threadpool( + save_docs_to_vector_db, request, docs=docs, collection_name=collection_name, @@ -1783,6 +1801,8 @@ async def process_text( collection_name = form_data.collection_name if collection_name is None: collection_name = calculate_sha256_string(form_data.content) + else: + await _validate_collection_access([collection_name], user, access_type='write') docs = [ Document( @@ -1824,6 +1844,8 @@ async def process_web( collection_name = form_data.collection_name if not collection_name: collection_name = calculate_sha256_string(form_data.url)[:63] + else: + await _validate_collection_access([collection_name], user, access_type='write') if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: await run_in_threadpool( @@ -2327,32 +2349,20 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen ) -async def _validate_collection_access(collection_names: list[str], user) -> None: +async def _validate_collection_access(collection_names: list[str], user, access_type: str = 'read') -> None: """ - Prevent users from querying collections they don't own. - Enforces ownership on user-memory-* and file-* collections. - Admins bypass this check. + Raise 403 if the user lacks access to any of the requested collections. + Delegates to the shared filter_accessible_collections utility so the + access rules stay in one place. """ - if user.role == 'admin': - return - - for name in collection_names: - if name.startswith('user-memory-') and name != f'user-memory-{user.id}': - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - elif name.startswith('file-'): - file_id = name[len('file-') :] - if not await has_access_to_file( - file_id=file_id, - access_type='read', - user=user, - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + requested = set(collection_names) + allowed = await filter_accessible_collections(requested, user, access_type=access_type) + denied = requested - allowed + if denied: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) class QueryDocForm(BaseModel): @@ -2375,7 +2385,7 @@ async def query_doc_handler( try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): collection_results = {} - collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( + collection_results[form_data.collection_name] = await ASYNC_VECTOR_DB_CLIENT.get( collection_name=form_data.collection_name ) return await query_doc_with_hybrid_search( @@ -2404,7 +2414,10 @@ async def query_doc_handler( query_embedding = await request.app.state.EMBEDDING_FUNCTION( form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user ) - return query_doc( + # query_doc wraps a blocking VECTOR_DB_CLIENT.search call; + # offload so the request's event loop stays responsive. + return await asyncio.to_thread( + query_doc, collection_name=form_data.collection_name, query_embedding=query_embedding, k=form_data.k if form_data.k else request.app.state.config.TOP_K, @@ -2502,7 +2515,7 @@ async def delete_entries_from_collection( db: AsyncSession = Depends(get_async_session), ): try: - if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( @@ -2511,13 +2524,37 @@ async def delete_entries_from_collection( ) hash = file.hash - VECTOR_DB_CLIENT.delete( + # Refuse to issue a `filter={'hash': None}` query — the + # match semantics of a null filter value are + # backend-dependent (some backends ignore the key, some + # match every row whose metadata lacks `hash`) and risk + # deleting unrelated entries. Files without a hash are + # typically unprocessed / failed / legacy records that + # can't be targeted by hash anyway. + if hash is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('File has no hash; cannot delete vector entries by hash.'), + ) + + # Pre-existing bug: this used `metadata=` which is not a + # parameter on `VectorDBBase.delete` nor on any backend + # implementation, so the call always raised TypeError that + # was silently swallowed by the surrounding `except + # Exception` and the endpoint reported `{'status': False}` + # for every request. Use `filter` to actually do what the + # endpoint name promises. + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=form_data.collection_name, - metadata={'hash': hash}, + filter={'hash': hash}, ) return {'status': True} else: return {'status': False} + except HTTPException: + # Caller-meaningful errors (404/400 above) must not be + # swallowed and re-shaped as `{'status': False}`. + raise except Exception as e: log.exception(e) return {'status': False} @@ -2525,7 +2562,7 @@ async def delete_entries_from_collection( @router.post('/reset/db') async def reset_vector_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): - VECTOR_DB_CLIENT.reset() + await ASYNC_VECTOR_DB_CLIENT.reset() await Knowledges.delete_all_knowledge(db=db) diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 0bb5813e6f..b921f7b3e6 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -18,7 +18,7 @@ from open_webui.utils.task import ( moa_response_generation_template, ) from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.constants import TASKS +from open_webui.constants import ERROR_MESSAGES, TASKS from open_webui.routers.pipelines import process_pipeline_inlet_filter @@ -168,7 +168,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -245,7 +245,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -313,7 +313,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -375,7 +375,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -431,13 +431,13 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Search query generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'), ) elif type == 'retrieval': if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Query generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'), ) if getattr(request.state, 'cached_queries', None): @@ -455,7 +455,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -508,7 +508,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Autocompletion generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'), ) type = form_data.get('type') @@ -519,7 +519,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}', + detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH), ) if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): @@ -533,7 +533,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -595,7 +595,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -661,7 +661,7 @@ async def generate_moa_response(request: Request, form_data: dict, user=Depends( if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 041a866e37..d70b4038fe 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -285,7 +285,7 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f'Error importing tool: {e}') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e)) ############################ diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 9fd2479ada..d8bfcefac8 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -276,14 +276,8 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi async def get_user_settings_by_session_user( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) ): - user = await Users.get_user_by_id(user.id, db=db) - if user: - return user.settings - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + return user.settings ############################ @@ -339,14 +333,8 @@ async def get_user_status_by_session_user( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - user = await Users.get_user_by_id(user.id, db=db) - if user: - return user - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + return user ############################ @@ -366,15 +354,14 @@ async def update_user_status_by_session_user( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - user = await Users.get_user_by_id(user.id, db=db) - if user: - user = await Users.update_user_status_by_id(user.id, form_data, db=db) - return user - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + updated = await Users.update_user_status_by_id(user.id, form_data, db=db) + if updated: + return updated + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) ############################ @@ -384,14 +371,8 @@ async def update_user_status_by_session_user( @router.get('/user/info', response_model=Optional[dict]) async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): - user = await Users.get_user_by_id(user.id, db=db) - if user: - return user.info - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + return user.info ############################ @@ -403,19 +384,13 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy async def update_user_info_by_session_user( form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) ): - user = await Users.get_user_by_id(user.id, db=db) - if user: - if user.info is None: - user.info = {} - - user = await Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db) - if user: - return user.info - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # Merges against the auth-time snapshot of user.info. The previous pre-merge + # refetch only narrowed (did not eliminate) the lost-update window on concurrent + # same-user writes; real safety needs row locking or a version column. + existing_info = user.info or {} + updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db) + if updated: + return updated.info else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index c79d8fe5d8..20705c2c44 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -45,7 +45,7 @@ async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_v if not request.app.state.config.ENABLE_CODE_EXECUTION: raise HTTPException( status_code=403, - detail='Code execution is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'), ) if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter': @@ -69,7 +69,7 @@ async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_v else: raise HTTPException( status_code=400, - detail='Code execution engine not supported', + detail=ERROR_MESSAGES.DEFAULT('Code execution engine not supported'), ) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 2c44eb25c5..e224408742 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -697,6 +697,31 @@ async def yjs_document_update(sid, data): log.warning(f'Session {sid} not in room {room}. Rejecting update.') return + # Verify write permission — room membership only proves read access + user = SESSION_POOL.get(sid) + if not user: + return + + if document_id.startswith('note:'): + note_id = document_id.split(':')[1] + note = await Notes.get_note_by_id(note_id) + if not note: + log.error(f'Note {note_id} not found') + return + + if ( + user.get('role') != 'admin' + and user.get('id') != note.user_id + and not await AccessGrants.has_access( + user_id=user.get('id'), + resource_type='note', + resource_id=note.id, + permission='write', + ) + ): + log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') + return + try: await stop_item_tasks(REDIS, document_id) except Exception: @@ -724,10 +749,6 @@ async def yjs_document_update(sid, data): skip_sid=sid, ) - user = SESSION_POOL.get(sid) - if not user: - return - async def debounced_save(): await asyncio.sleep(0.5) await document_save_handler(document_id, data.get('data', {}), user) @@ -743,7 +764,7 @@ async def yjs_document_update(sid, data): async def yjs_document_leave(sid, data): """Handle user leaving a document""" try: - document_id = data['document_id'] + document_id = normalize_document_id(data['document_id']) user_id = data.get('user_id', sid) log.info(f'User {user_id} leaving document {document_id}') diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 61cd5ede4e..508e7d8b8f 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -37,7 +37,7 @@ from open_webui.models.channels import Channels, ChannelMember, Channel from open_webui.models.messages import Messages, Message from open_webui.models.groups import Groups from open_webui.models.memories import Memories -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.utils.sanitize import sanitize_code log = logging.getLogger(__name__) @@ -653,7 +653,7 @@ async def delete_memory( result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id) if result: - VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return json.dumps( {'status': 'success', 'message': f'Memory {memory_id} deleted'}, ensure_ascii=False, @@ -2202,7 +2202,7 @@ async def query_knowledge_bases( import heapq from open_webui.models.knowledge import Knowledges from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION - from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT + from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT user_id = __user__.get('id') user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] @@ -2227,7 +2227,7 @@ async def query_knowledge_bases( accessible_ids = [kb.id for kb in accessible_knowledge_bases.items] - search_results = VECTOR_DB_CLIENT.search( + search_results = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=KNOWLEDGE_BASES_COLLECTION, vectors=[query_embedding], filter={'knowledge_base_id': {'$in': accessible_ids}}, @@ -2494,7 +2494,6 @@ async def create_automation( name: str, prompt: str, rrule: str, - model_id: Optional[str] = None, __request__: Request = None, __user__: dict = None, __metadata__: dict = None, @@ -2502,6 +2501,7 @@ async def create_automation( """ Create a scheduled automation that runs a prompt on a recurring or one-time schedule. Use this when the user wants to schedule a task to run automatically. + The automation will use the current chat model. The rrule parameter must be a valid iCalendar RRULE string. Common examples: - Every day at 9am: "DTSTART:20250101T090000\\nRRULE:FREQ=DAILY" @@ -2516,7 +2516,6 @@ async def create_automation( :param name: A short descriptive name for the automation :param prompt: The prompt/instructions to execute on each run :param rrule: An iCalendar RRULE string defining the schedule - :param model_id: Optional model ID to use. Defaults to the current chat model if omitted. :return: JSON with the created automation details including id, next scheduled runs """ if __request__ is None: @@ -2535,11 +2534,10 @@ async def create_automation( if not user: return json.dumps({'error': 'User not found'}) - # Default to current chat's model if not specified + # Always use the calling model for the automation + model_id = (__metadata__ or {}).get('model_id') if not model_id: - model_id = (__metadata__ or {}).get('model_id') or (__metadata__ or {}).get('model') - if not model_id: - return json.dumps({'error': 'model_id is required (could not detect current model)'}) + return json.dumps({'error': 'Could not detect current model'}) # Validate the RRULE try: diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 41c888d441..06e8d0aba0 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -257,6 +257,47 @@ async def filter_allowed_access_grants( return access_grants +async def has_base_model_access( + user_id: str, + model_info, + *, + user_group_ids: set[str] | None = None, + db=None, +) -> bool: + """ + Walk the ``base_model_id`` chain and verify the caller has read access + at every hop. + + Returns ``True`` when access is granted (or the chain ends at a raw + provider model that has no per-model ACL). Returns ``False`` the + moment a registered base model denies access. + """ + from open_webui.models.models import Models + from open_webui.models.access_grants import AccessGrants + + base_model_id = getattr(model_info, 'base_model_id', None) + seen = {model_info.id} + while base_model_id and base_model_id not in seen: + seen.add(base_model_id) + base_model_info = await Models.get_model_by_id(base_model_id, db=db) + if base_model_info is None: + break # Raw provider model — no per-model ACL + if not ( + user_id == base_model_info.user_id + or await AccessGrants.has_access( + user_id=user_id, + resource_type='model', + resource_id=base_model_info.id, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + ): + return False + base_model_id = getattr(base_model_info, 'base_model_id', None) + return True + + async def check_model_access( user: UserModel, model_info, @@ -296,6 +337,10 @@ async def check_model_access( ) ): raise HTTPException(status_code=403, detail='Model not found') + + # Enforce access on chained base models + if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): + raise HTTPException(status_code=403, detail='Model not found') else: if user.role != 'admin': raise HTTPException(status_code=403, detail='Model not found') diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index 5e7efb5b26..a48dfeb0f1 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -66,10 +66,18 @@ async def has_access_to_file( return True # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats - chats = await Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True + shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db) + if shared_chat_ids: + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='shared_chat', + resource_ids=shared_chat_ids, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + if accessible_ids: + return True # Check if the file is directly attached to a shared workspace model for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 5ba4099fb4..a01184143f 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -181,17 +181,99 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: ) elif block_type == 'tool_result': # Tool results become separate tool messages in OpenAI format - tool_content = block.get('content', '') - if isinstance(tool_content, list): - tool_text_parts = [] - for tc in tool_content: - if isinstance(tc, dict) and tc.get('type') == 'text': - tool_text_parts.append(tc.get('text', '')) - tool_content = '\n'.join(tool_text_parts) + tool_result_content = block.get('content', '') + tool_content: str | list = '' + + if isinstance(tool_result_content, str): + tool_content = tool_result_content + elif isinstance(tool_result_content, list): + # Build a multimodal content array to preserve + # images and other non-text content types. + converted_parts = [] + for content_block in tool_result_content: + if not isinstance(content_block, dict): + continue + content_type = content_block.get('type', 'text') + + if content_type == 'text': + converted_parts.append( + { + 'type': 'text', + 'text': content_block.get('text', ''), + } + ) + elif content_type == 'image': + source = content_block.get('source', {}) + if source.get('type') == 'base64': + media_type = source.get('media_type', 'image/png') + data = source.get('data', '') + converted_parts.append( + { + 'type': 'image_url', + 'image_url': { + 'url': f'data:{media_type};base64,{data}', + }, + } + ) + elif source.get('type') == 'url': + converted_parts.append( + { + 'type': 'image_url', + 'image_url': { + 'url': source.get('url', ''), + }, + } + ) + elif content_type == 'document': + # Documents have no direct OpenAI equivalent; + # convert to a text representation. + document_source = content_block.get('source', {}) + document_title = content_block.get('title', 'Document') + document_context = content_block.get('context', '') + document_text = f'[Document: {document_title}]' + if document_context: + document_text += f'\n{document_context}' + if document_source.get('type') == 'text' and document_source.get('data'): + document_text += f'\n{document_source["data"]}' + converted_parts.append({'type': 'text', 'text': document_text}) + elif content_type == 'search_result': + # Convert search results to a text + # representation with source attribution. + search_title = content_block.get('title', '') + search_url = content_block.get('source', '') + search_content_blocks = content_block.get('content', []) + search_texts = [] + for search_block in search_content_blocks: + if isinstance(search_block, dict) and search_block.get('type') == 'text': + search_texts.append(search_block.get('text', '')) + search_body = '\n'.join(search_texts) + search_text = f'[Search Result: {search_title}]' + if search_url: + search_text += f'\nSource: {search_url}' + if search_body: + search_text += f'\n{search_body}' + converted_parts.append({'type': 'text', 'text': search_text}) + + # Flatten to string when only text parts are present + if all(part.get('type') == 'text' for part in converted_parts): + tool_content = '\n'.join(part.get('text', '') for part in converted_parts) + elif converted_parts: + tool_content = converted_parts + else: + tool_content = '' # Propagate error status if present if block.get('is_error'): - tool_content = f'Error: {tool_content}' + if isinstance(tool_content, str): + tool_content = f'Error: {tool_content}' + elif isinstance(tool_content, list): + tool_content.insert( + 0, + { + 'type': 'text', + 'text': 'Error: ', + }, + ) messages.append( { diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py new file mode 100644 index 0000000000..05389d8f94 --- /dev/null +++ b/backend/open_webui/utils/asgi_middleware.py @@ -0,0 +1,268 @@ +""" +Pure-ASGI replacements for the project's previous +`@app.middleware('http')` / `BaseHTTPMiddleware` middlewares. + +Why this matters +---------------- +Starlette's `BaseHTTPMiddleware` (which `@app.middleware('http')` is +sugar for) runs the downstream app inside an `anyio` task group. When +the wrapper exits — for any reason: response complete, client +disconnect, an outer middleware bailing out — the task group cancels +the inner task. That `CancelledError` then propagates into whatever +the inner task was doing, including in-flight DB queries, embedding +calls and disk I/O. + +In Open WebUI this surfaces as: + +* SQLAlchemy logging multi-page `NotImplementedError: + terminate_force_close()` tracebacks at ERROR every time a request is + cancelled mid-DB-call (the aiosqlite connector cleanup path). +* Spurious cancellations cascading through the four stacked + `@app.middleware('http')` wrappers. + +Pure ASGI middleware does not introduce a cancel scope around the +downstream app, so client disconnects propagate the way ASGI was +designed to (via `receive()` returning `http.disconnect`) instead of +being injected as `CancelledError` into arbitrary `await` points. + +Reference: https://www.starlette.io/middleware/#limitations +""" + +from __future__ import annotations + +import logging +import re +import time +from urllib.parse import parse_qs, urlencode + +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.security import HTTPAuthorizationCredentials +from starlette.datastructures import MutableHeaders +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from open_webui.internal.db import ScopedSession +from open_webui.utils.auth import get_http_authorization_cred + +log = logging.getLogger(__name__) + + +class CommitSessionMiddleware: + """Commit and release the thread-local sync `ScopedSession` after each + HTTP request. + + Most requests now use the async session; the sync ScopedSession is + only touched by startup, healthchecks, and a handful of legacy + helpers (notably the pgvector / opengauss vector-DB clients). The + middleware exists so that PostgreSQL connections do not accumulate + as "idle in transaction" and so that any pending sync work made + inside the request is durably persisted. + + Failure semantics + ----------------- + * Downstream raised → roll back any pending sync work, release the + connection, and re-raise so the outer exception middleware can + turn it into an error response. We never commit work on a + request that did not complete successfully. + * Downstream returned → commit pending sync work; on commit + failure, log loudly, roll back, and re-raise. Note that in pure + ASGI the response messages have already been emitted by the + time `await self.app(...)` returns, so a commit failure cannot + retroactively change what the client sees on the wire — but + re-raising still surfaces the error in logs and to ASGI servers + that expose it. We deliberately do not buffer the response to + gate it on commit success, because that would defeat streaming + responses (chat completions, SSE) which are core to the app. + + For request paths where commit-before-send is required, manage the + sync session explicitly inside the handler instead of relying on + this middleware. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + try: + await self.app(scope, receive, send) + except BaseException: + # Downstream did not complete successfully. Roll back any + # pending sync writes, release the connection, and let the + # exception propagate. + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after downstream error') + finally: + ScopedSession.remove() + raise + + # Downstream completed. Commit pending sync work. + try: + ScopedSession.commit() + except Exception: + log.exception('CommitSessionMiddleware: post-request commit failed; response was already sent to client') + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after commit failure') + raise + finally: + # CRITICAL: remove() returns the connection to the pool. + # Without this, connections remain "checked out" and + # accumulate as "idle in transaction" in PostgreSQL. + ScopedSession.remove() + + +class AuthTokenMiddleware: + """Extract the bearer/cookie/x-api-key credential and stash it on + `request.state.token`. + + Routes that depend on `get_verified_user` etc. read this state. + Also exposes `request.state.enable_api_keys` (snapshotted at request + entry from runtime config) and stamps an `X-Process-Time` response + header. + """ + + def __init__(self, app: ASGIApp, *, fastapi_app) -> None: + self.app = app + self._fastapi_app = fastapi_app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + start_time = time.monotonic() + request = Request(scope) + + token = get_http_authorization_cred(request.headers.get('Authorization')) + if token is None: + cookie_token = request.cookies.get('token') + if cookie_token: + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token) + if token is None: + api_key = request.headers.get('x-api-key') + if api_key: + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) + + request.state.token = token + request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS + + async def send_with_timing(message: Message) -> None: + if message['type'] == 'http.response.start': + process_time = int(time.monotonic() - start_time) + headers = MutableHeaders(scope=message) + headers['X-Process-Time'] = str(process_time) + await send(message) + + await self.app(scope, receive, send_with_timing) + + +class WebsocketUpgradeGuardMiddleware: + """Reject HTTP requests to `/ws/socket.io` that claim + `transport=websocket` but lack the proper `Upgrade`/`Connection` + headers. + + Works around https://github.com/miguelgrinberg/python-engineio/issues/367 + where engineio mishandles such requests. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + path = scope.get('path', '') + if '/ws/socket.io' in path: + query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + query_params = parse_qs(query_string) + if query_params.get('transport', [''])[0] == 'websocket': + headers = _scope_headers(scope) + upgrade = headers.get('upgrade', '').lower() + connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')] + if upgrade != 'websocket' or 'upgrade' not in connection_tokens: + response = JSONResponse( + status_code=400, + content={'detail': 'Invalid WebSocket upgrade request'}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +class RedirectMiddleware: + """Rewrites a couple of legacy entry-points to the SPA's own routes: + + * ``GET /watch?v=ID`` (YouTube) → ``/?youtube=ID`` + * ``GET /?shared=…`` (PWA share-target) → ``/?youtube=…`` / + ``/?load-url=…`` / ``/?q=…`` + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http' or scope.get('method', '').upper() != 'GET': + await self.app(scope, receive, send) + return + + path = scope.get('path', '') + query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + query_params = parse_qs(query_string) + + redirect_params: dict[str, str] = {} + if path.endswith('/watch') and 'v' in query_params and query_params['v']: + redirect_params['youtube'] = query_params['v'][0] + + if 'shared' in query_params and query_params['shared']: + text = query_params['shared'][0] + if text: + url_match = re.match(r'https://\S+', text) + if url_match: + # Local import: youtube loader pulls heavy deps and is + # only needed when a share-target actually contains a + # YouTube URL. + from open_webui.retrieval.loaders.youtube import _parse_video_id + + youtube_video_id = _parse_video_id(url_match[0]) + if youtube_video_id: + redirect_params['youtube'] = youtube_video_id + else: + redirect_params['load-url'] = url_match[0] + else: + redirect_params['q'] = text + + if redirect_params: + redirect_url = f'/?{urlencode(redirect_params)}' + response = RedirectResponse(url=redirect_url) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +def _scope_headers(scope: Scope) -> dict[str, str]: + """Return ASGI scope headers as a lower-cased str→str dict. + + ASGI delivers headers as a list of (bytes, bytes) pairs. For + convenience, fold duplicate keys with comma-joining (matching + HTTP/1.1 semantics). + """ + decoded: dict[str, str] = {} + for raw_key, raw_value in scope.get('headers', []): + key = raw_key.decode('latin-1').lower() + value = raw_value.decode('latin-1') + if key in decoded: + decoded[key] = f'{decoded[key]}, {value}' + else: + decoded[key] = value + return decoded diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 5997b0e58b..3866eb865a 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -22,6 +22,7 @@ from dateutil.rrule import rrulestr from fastapi import Request from starlette.datastructures import Headers +from open_webui.constants import ERROR_MESSAGES from open_webui.models.automations import Automations, AutomationRuns, AutomationModel from open_webui.models.chats import ChatForm, Chats from open_webui.models.users import Users @@ -59,9 +60,9 @@ def validate_rrule(s: str) -> None: try: rule = _parse_rule(s) except Exception as e: - raise ValueError(f'Invalid RRULE: {e}') + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) if rule.after(datetime.now()) is None: - raise ValueError('RRULE has no future occurrences') + raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS) def next_run_ns(s: str, tz: str = None) -> Optional[int]: @@ -306,8 +307,9 @@ async def execute_automation(app, automation: AutomationModel) -> None: user_msg_id = str(uuid4()) assistant_msg_id = str(uuid4()) - # Create the chat with user message (same structure as frontend) + chat_id = str(uuid4()) chat = await Chats.insert_new_chat( + chat_id, automation.user_id, ChatForm( chat={ @@ -377,7 +379,13 @@ async def execute_automation(app, automation: AutomationModel) -> None: 'stream': True, 'chat_id': chat.id, 'id': assistant_msg_id, - 'parent_id': user_msg_id, + 'parent_id': None, # Root message (chat already created above) + 'user_message': { + 'id': user_msg_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + }, 'session_id': f'automation:{automation.id}', 'background_tasks': {}, } diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 21e3af5752..78392f2d41 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -20,12 +20,14 @@ from open_webui.models.files import Files from open_webui.routers.files import upload_file_handler from open_webui.retrieval.web.utils import validate_url +import asyncio import mimetypes import base64 import io import re -import requests +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.utils.session_pool import get_session BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE) MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE) @@ -37,19 +39,20 @@ async def get_image_base64_from_url(url: str) -> Optional[str]: # Validate URL to prevent SSRF attacks against local/private networks validate_url(url) # Download the image from the URL - response = requests.get(url) - response.raise_for_status() - image_data = response.content - encoded_string = base64.b64encode(image_data).decode('utf-8') - content_type = response.headers.get('Content-Type', 'image/png') - return f'data:{content_type};base64,{encoded_string}' + session = await get_session() + async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response: + response.raise_for_status() + image_data = await response.read() + encoded_string = base64.b64encode(image_data).decode('utf-8') + content_type = response.headers.get('Content-Type', 'image/png') + return f'data:{content_type};base64,{encoded_string}' else: file = await Files.get_file_by_id(url) if not file: return None - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) if file_path.is_file(): @@ -68,7 +71,7 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user if BASE64_IMAGE_URL_PREFIX.match(base64_image_string): image_url = '' # Extract base64 image data from the line - image_data, content_type = get_image_data(base64_image_string) + image_data, content_type = await get_image_data(base64_image_string) if image_data is not None: _, image_url = await upload_image( request, @@ -170,7 +173,7 @@ async def get_image_base64_from_file_id(id: str) -> Optional[str]: return None try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 497808c22d..9172f1c325 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -1,49 +1,51 @@ -import asyncio import json import logging import random -import requests -import aiohttp import urllib.parse -import urllib.request from typing import Optional -import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client) +import aiohttp from pydantic import BaseModel +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.utils.session_pool import get_session + log = logging.getLogger(__name__) default_headers = {'User-Agent': 'Mozilla/5.0'} -def queue_prompt(prompt, client_id, base_url, api_key): +async def queue_prompt(prompt, client_id, base_url, api_key): log.info('queue_prompt') p = {'prompt': prompt, 'client_id': client_id} - data = json.dumps(p).encode('utf-8') - log.debug(f'queue_prompt data: {data}') + log.debug(f'queue_prompt data: {p}') try: - req = urllib.request.Request( + session = await get_session() + async with session.post( f'{base_url}/prompt', - data=data, + json=p, headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - response = urllib.request.urlopen(req).read() - return json.loads(response) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() except Exception as e: log.exception(f'Error while queuing prompt: {e}') - raise e + raise -def get_image(filename, subfolder, folder_type, base_url, api_key): +async def get_image(filename, subfolder, folder_type, base_url, api_key): log.info('get_image') data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type} url_values = urllib.parse.urlencode(data) - req = urllib.request.Request( + session = await get_session() + async with session.get( f'{base_url}/view?{url_values}', headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - with urllib.request.urlopen(req) as response: - return response.read() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.read() def get_image_url(filename, subfolder, folder_type, base_url): @@ -53,32 +55,39 @@ def get_image_url(filename, subfolder, folder_type, base_url): return f'{base_url}/view?{url_values}' -def get_history(prompt_id, base_url, api_key): +async def get_history(prompt_id, base_url, api_key): log.info('get_history') - - req = urllib.request.Request( + session = await get_session() + async with session.get( f'{base_url}/history/{prompt_id}', headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - with urllib.request.urlopen(req) as response: - return json.loads(response.read()) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() -def get_images(ws, workflow, client_id, base_url, api_key): - prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id'] +async def _ws_get_images(ws, workflow, client_id, base_url, api_key): + """Queue a prompt and wait on *ws* for ComfyUI to finish executing it. + + Returns a dict of ``{'data': [{'url': ...}, ...]}``. + """ + prompt_id = (await queue_prompt(workflow, client_id, base_url, api_key))['prompt_id'] output_images = [] - while True: - out = ws.recv() - if isinstance(out, str): - message = json.loads(out) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + message = json.loads(msg.data) if message['type'] == 'executing': data = message['data'] if data['node'] is None and data['prompt_id'] == prompt_id: break # Execution is done - else: - continue # previews are binary data + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + log.error(f'WebSocket closed unexpectedly: {msg.type}') + break + # binary messages (previews) are silently skipped - history = get_history(prompt_id, base_url, api_key)[prompt_id] + history = (await get_history(prompt_id, base_url, api_key))[prompt_id] for node_id in history['outputs']: node_output = history['outputs'][node_id] if node_id in workflow and workflow[node_id].get('class_type') in [ @@ -105,10 +114,10 @@ async def comfyui_upload_image(image_file_item, base_url, api_key): form.add_field('image', file_bytes, filename=filename, content_type=mime_type) form.add_field('type', 'input') # required by ComfyUI - async with aiohttp.ClientSession() as session: - async with session.post(url, data=form, headers=headers) as resp: - resp.raise_for_status() - return await resp.json() + session = await get_session() + async with session.post(url, data=form, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + resp.raise_for_status() + return await resp.json() class ComfyUINodeInput(BaseModel): @@ -136,11 +145,9 @@ class ComfyUICreateImageForm(BaseModel): seed: Optional[int] = None -async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): - ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') - workflow = json.loads(payload.workflow.workflow) - - for node in payload.workflow.nodes: +def _apply_workflow_nodes(workflow, nodes, model, payload): + """Mutate *workflow* dict in-place based on typed node definitions.""" + for node in nodes: if node.type: if node.type == 'model': for node_id in node.node_ids: @@ -151,6 +158,14 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie elif node.type == 'negative_prompt': for node_id in node.node_ids: workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt + elif node.type == 'image': + if isinstance(payload.image, list): + for idx, node_id in enumerate(node.node_ids): + if idx < len(payload.image): + workflow[node_id]['inputs'][node.key] = payload.image[idx] + else: + for node_id in node.node_ids: + workflow[node_id]['inputs'][node.key] = payload.image elif node.type == 'width': for node_id in node.node_ids: workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width @@ -171,24 +186,31 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie for node_id in node.node_ids: workflow[node_id]['inputs'][node.key] = node.value + +async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): + ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') + workflow = json.loads(payload.workflow.workflow) + _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) + + headers = {'Authorization': f'Bearer {api_key}'} + session = await get_session() + try: - ws = websocket.WebSocket() - headers = {'Authorization': f'Bearer {api_key}'} - ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) - log.info('WebSocket connection established.') - except Exception as e: + async with session.ws_connect( + f'{ws_url}/ws?clientId={client_id}', + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as ws: + log.info('WebSocket connection established.') + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) + except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') return None - - try: - log.info('Sending workflow to WebSocket server.') - log.info(f'Workflow: {workflow}') - images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f'Error while receiving images: {e}') - images = None - - ws.close() + log.exception(f'Error during image generation: {e}') + return None return images @@ -209,64 +231,26 @@ class ComfyUIEditImageForm(BaseModel): async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key): ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') workflow = json.loads(payload.workflow.workflow) + _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) - for node in payload.workflow.nodes: - if node.type: - if node.type == 'model': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = model - elif node.type == 'image': - if isinstance(payload.image, list): - # check if multiple images are provided - for idx, node_id in enumerate(node.node_ids): - if idx < len(payload.image): - workflow[node_id]['inputs'][node.key] = payload.image[idx] - else: - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = payload.image - elif node.type == 'prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt - elif node.type == 'negative_prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt - elif node.type == 'width': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width - elif node.type == 'height': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height - elif node.type == 'n': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n - elif node.type == 'steps': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps - elif node.type == 'seed': - seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = seed - else: - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = node.value + headers = {'Authorization': f'Bearer {api_key}'} + session = await get_session() try: - ws = websocket.WebSocket() - headers = {'Authorization': f'Bearer {api_key}'} - ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) - log.info('WebSocket connection established.') - except Exception as e: + async with session.ws_connect( + f'{ws_url}/ws?clientId={client_id}', + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as ws: + log.info('WebSocket connection established.') + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) + except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') return None - - try: - log.info('Sending workflow to WebSocket server.') - log.info(f'Workflow: {workflow}') - images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f'Error while receiving images: {e}') - images = None - - ws.close() + log.exception(f'Error during image editing: {e}') + return None return images diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 4d4a3726c3..d44112b4ec 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -401,12 +401,61 @@ def is_opening_code_block(content): return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 +_OPENAI_TOOL_DISPLAY_NAMES = { + 'web_search_call': 'Web Search', + 'file_search_call': 'File Search', + 'computer_call': 'Computer Use', +} + + +def _render_openai_tool_call_handler(item: dict, done: bool) -> str: + """Render an OpenAI Responses API server-side tool item as a
block. + + Handles web_search_call, file_search_call, and computer_call items whose + schemas are defined in the openai-python SDK (generated from OpenAPI spec). + """ + item_type = item.get('type', '') + call_id = item.get('id', '') + display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type) + + # Build a short summary of what the tool did + summary = '' + if item_type == 'web_search_call': + action = item.get('action', {}) + if isinstance(action, dict): + atype = action.get('type', '') + if atype == 'search': + queries = action.get('queries') or [] + query = action.get('query', '') + summary = f'Search: {", ".join(str(q) for q in queries)}' if queries else (f'Search: {query}' if query else '') + elif atype == 'open_page': + summary = f'Open page: {action.get("url", "")}' if action.get('url') else '' + elif atype == 'find_in_page': + summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else '' + elif item_type == 'file_search_call': + queries = item.get('queries', []) + if queries: + summary = f'Queries: {", ".join(str(q) for q in queries)}' + elif item_type == 'computer_call': + action = item.get('action') + actions = item.get('actions') + if isinstance(action, dict): + summary = f'Action: {action.get("type", "unknown")}' + elif isinstance(actions, list) and actions: + summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}' + + escaped_name = html.escape(display_name) + if done: + return f'
\nTool Executed\n{html.escape(summary)}\n
\n' + return f'
\nExecuting...\n
\n' + + def serialize_output(output: list) -> str: """ Convert OR-aligned output items to HTML for display. For LLM consumption, use convert_output_to_messages() instead. """ - content = '' + parts: list[str] = [] # First pass: collect function_call_output items by call_id for lookup tool_outputs = {} @@ -423,46 +472,48 @@ def serialize_output(output: list) -> str: if 'text' in content_part: text = content_part.get('text', '').strip() if text: - content = f'{content}{text}\n' + parts.append(text) elif item_type == 'function_call': - # Render tool call inline with its result (if available) - if content and not content.endswith('\n'): - content += '\n' - call_id = item.get('call_id', '') name = item.get('name', '') arguments = item.get('arguments', '') result_item = tool_outputs.get(call_id) if result_item: - result_text = '' + result_parts: list[str] = [] for result_output in result_item.get('output', []): if 'text' in result_output: output_text = result_output.get('text', '') - result_text += str(output_text) if not isinstance(output_text, str) else output_text + result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text) + result_text = ''.join(result_parts) files = result_item.get('files') embeds = result_item.get('embeds', '') - content += f'
\nTool Executed\n
\n' + parts.append(f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
') else: - content += f'
\nExecuting...\n
\n' + parts.append(f'
\nExecuting...\n
') elif item_type == 'function_call_output': # Already handled inline with function_call above pass + elif item_type in _OPENAI_TOOL_DISPLAY_NAMES: + status = item.get('status', 'in_progress') + done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1 + parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n')) + elif item_type == 'reasoning': - reasoning_content = '' + reasoning_parts: list[str] = [] # Check for 'summary' (new structure) or 'content' (legacy/fallback) source_list = item.get('summary', []) or item.get('content', []) for content_part in source_list: if 'text' in content_part: - reasoning_content += content_part.get('text', '') + reasoning_parts.append(content_part.get('text', '')) elif 'summary' in content_part: # Handle potential nested logic if any pass - reasoning_content = reasoning_content.strip() + reasoning_content = ''.join(reasoning_parts).strip() duration = item.get('duration') status = item.get('status', 'in_progress') @@ -471,9 +522,6 @@ def serialize_output(output: list) -> str: # render as done (a subsequent item means reasoning is complete) is_last_item = idx == len(output) - 1 - if content and not content.endswith('\n'): - content += '\n' - display = html.escape( '\n'.join( (f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines() @@ -481,19 +529,22 @@ def serialize_output(output: list) -> str: ) if status == 'completed' or duration is not None or not is_last_item: - content = f'{content}
\nThought for {duration or 0} seconds\n{display}\n
\n' + parts.append(f'
\nThought for {duration or 0} seconds\n{display}\n
') else: - content = f'{content}
\nThinking…\n{display}\n
\n' + parts.append(f'
\nThinking…\n{display}\n
') elif item_type == 'open_webui:code_interpreter': + # Code interpreter needs to inspect/mutate prior accumulated content + # to strip trailing unclosed code fences — materialize only here. + content = '\n'.join(parts) content_stripped, original_whitespace = split_content_and_whitespace(content) if is_opening_code_block(content_stripped): content = content_stripped.rstrip('`').rstrip() + original_whitespace else: content = content_stripped + original_whitespace - if content and not content.endswith('\n'): - content += '\n' + # Re-split back into parts list after mutation + parts = [content] if content else [] # Render the code_interpreter item as a
block # so the frontend Collapsible renders "Analyzing..."/"Analyzed". @@ -519,11 +570,11 @@ def serialize_output(output: list) -> str: output_attr = f' output="{html.escape(output_json)}"' if status == 'completed' or duration is not None or not is_last_item: - content += f'
\nAnalyzed\n{display}\n
\n' + parts.append(f'
\nAnalyzed\n{display}\n
') else: - content += f'
\nAnalyzing…\n{display}\n
\n' + parts.append(f'
\nAnalyzing…\n{display}\n
') - return content.strip() + return '\n'.join(parts).strip() def deep_merge(target, source): @@ -2054,13 +2105,16 @@ async def convert_url_images_to_base64(form_data): continue try: - base64_data = await asyncio.to_thread(get_image_base64_from_url, image_url) - new_content.append( - { - 'type': 'image_url', - 'image_url': {'url': base64_data}, - } - ) + base64_data = await get_image_base64_from_url(image_url) + if base64_data: + new_content.append( + { + 'type': 'image_url', + 'image_url': {'url': base64_data}, + } + ) + else: + new_content.append(item) except Exception as e: log.debug(f'Error converting image URL to base64: {e}') new_content.append(item) @@ -2150,10 +2204,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Load messages from DB when available — DB preserves structured 'output' items # which the frontend strips, causing tool calls to be merged into content. chat_id = metadata.get('chat_id') - parent_message_id = metadata.get('parent_message_id') + user_message_id = metadata.get('user_message_id') - if chat_id and parent_message_id and not chat_id.startswith('local:'): - db_messages = await load_messages_from_db(chat_id, parent_message_id) + if chat_id and user_message_id and not chat_id.startswith('local:'): + db_messages = await load_messages_from_db(chat_id, user_message_id) if db_messages: system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = [system_message, *db_messages] if system_message else db_messages @@ -2457,6 +2511,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata = { **metadata, + 'model_id': form_data.get('model'), 'tool_ids': tool_ids, 'terminal_id': terminal_id, 'files': files, @@ -2615,7 +2670,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Resolve terminal tools if terminal_id is set (outside tool_ids check # so system terminals work even when no other tools are selected) - if terminal_id: + terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True) + if terminal_id and terminal_capability: try: terminal_result = await get_terminal_tools( request, @@ -3058,6 +3114,147 @@ async def background_tasks_handler(ctx): pass +async def outlet_filter_handler(ctx): + """Run outlet filters inline after chat completion. + + Replaces the separate POST /api/chat/completed round-trip. + Persists outlet-modified content to DB and emits a chat:outlet event + so the frontend can sync its in-memory state. + + For temp chats (local: prefix), messages are built from form_data + plus the assistant response message stored in ctx['assistant_message'], + since temp chats have no DB-persisted history. + """ + request = ctx['request'] + user = ctx['user'] + model = ctx['model'] + metadata = ctx['metadata'] + event_emitter = ctx.get('event_emitter') + event_caller = ctx.get('event_caller') + + chat_id = metadata.get('chat_id', '') + message_id = metadata.get('message_id') + + if not chat_id or not message_id: + return + + is_temp_chat = chat_id.startswith('local:') + + try: + messages_map = None + + if is_temp_chat: + # Temp chats have no DB record — build message list from + # the in-memory form_data plus the assistant response. + form_messages = ctx.get('form_data', {}).get('messages', []) + assistant_message = ctx.get('assistant_message', {}) + + message_list = [ + { + 'role': m.get('role'), + 'content': m.get('content', ''), + } + for m in form_messages + ] + + # Append the full assistant message (content, output, usage, etc.) + if assistant_message: + message_list.append( + { + 'id': message_id, + 'role': 'assistant', + **assistant_message, + } + ) + else: + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return + + message_list = get_message_list(messages_map, message_id) + if not message_list: + return + + model_id = model.get('id') if isinstance(model, dict) else model + + outlet_data = { + 'model': model_id, + 'messages': [ + { + 'id': m.get('id'), + 'role': m.get('role'), + 'content': m.get('content', ''), + 'info': m.get('info'), + 'timestamp': m.get('timestamp'), + **({'output': m['output']} if m.get('output') else {}), + **({'usage': m['usage']} if m.get('usage') else {}), + **({'sources': m['sources']} if m.get('sources') else {}), + } + for m in message_list + ], + 'filter_ids': metadata.get('filter_ids', []), + 'chat_id': chat_id, + 'session_id': metadata.get('session_id'), + 'id': message_id, + } + + # Pipeline outlet filters + models = request.app.state.MODELS + try: + outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models) + except Exception as e: + log.debug(f'Pipeline outlet filter error: {e}') + + # Function outlet filters + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__request__': request, + '__model__': model, + } + + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) + + outlet_result, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='outlet', + form_data=outlet_data, + extra_params=extra_params, + ) + + # Persist outlet-modified content and notify frontend + # (skip DB persistence for temp chats — they have no DB record) + if outlet_result and outlet_result.get('messages'): + if not is_temp_chat and messages_map: + for message in outlet_result['messages']: + outlet_message_id = message.get('id') + if outlet_message_id and outlet_message_id in messages_map: + original_message = messages_map[outlet_message_id] + if original_message.get('content') != message.get('content'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + outlet_message_id, + { + 'content': message['content'], + 'originalContent': original_message.get('content'), + }, + ) + + if event_emitter: + await event_emitter( + { + 'type': 'chat:outlet', + 'data': {'messages': outlet_result['messages']}, + } + ) + except Exception as e: + log.debug(f'Error running outlet filters: {e}') + + async def non_streaming_chat_response_handler(response, ctx): request = ctx['request'] @@ -3169,7 +3366,7 @@ async def non_streaming_chat_response_handler(response, ctx): await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, @@ -3179,6 +3376,12 @@ async def non_streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + ctx['assistant_message'] = { + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: @@ -3603,6 +3806,40 @@ async def streaming_chat_response_handler(response, ctx): elif data.get('type', '').startswith('response.'): output, response_metadata = handle_responses_streaming_event(data, output) + # Emit citation sources from finalized output items + # (mirrors Chat Completions annotation handling at delta level) + if data.get('type') == 'response.output_item.done': + item = data.get('item', {}) + if item.get('type') == 'message': + for part in item.get('content', []): + for annotation in part.get('annotations', []): + if annotation.get('type') == 'url_citation': + # Handle both flat (Responses API) and nested (Chat Completions) formats + url_citation = annotation.get('url_citation', annotation) + + url = url_citation.get('url', '') + title = url_citation.get('title', url) + + if url: + await event_emitter( + { + 'type': 'source', + 'data': { + 'source': { + 'name': title, + 'url': url, + }, + 'document': [title], + 'metadata': [ + { + 'source': url, + 'name': title, + } + ], + }, + } + ) + processed_data = { 'output': full_output(), 'content': serialize_output(full_output()), @@ -4673,7 +4910,7 @@ async def streaming_chat_response_handler(response, ctx): await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, @@ -4690,27 +4927,41 @@ async def streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + ctx['assistant_message'] = { + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) except asyncio.CancelledError: log.warning('Task was cancelled!') - await event_emitter({'type': 'chat:tasks:cancel'}) + try: + await asyncio.shield(event_emitter({'type': 'chat:tasks:cancel'})) - if not ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'done': True, - 'content': serialize_output(output), - 'output': output, - }, - ) - else: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'done': True}, - ) + if not ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + await asyncio.shield( + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + }, + ) + ) + else: + await asyncio.shield( + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) + ) + except Exception: + pass + raise # re-raise CancelledError for proper propagation if response.background is not None: await response.background() diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 7b52f0afdf..441f26a918 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -148,19 +148,22 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: messages = [] pending_tool_calls = [] pending_content = [] + pending_reasoning = '' def flush_pending(): - nonlocal pending_content, pending_tool_calls - if pending_content or pending_tool_calls: + nonlocal pending_content, pending_tool_calls, pending_reasoning + if pending_content or pending_tool_calls or pending_reasoning: messages.append( { 'role': 'assistant', 'content': '\n'.join(pending_content) if pending_content else '', **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), + **({'reasoning_content': pending_reasoning} if pending_reasoning else {}), } ) pending_content = [] pending_tool_calls = [] + pending_reasoning = '' for item in output: item_type = item.get('type', '') @@ -245,6 +248,10 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: start_tag = item.get('start_tag', '') end_tag = item.get('end_tag', '') pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') + # Preserve raw reasoning text as reasoning_content for + # providers that require it on assistant tool-call messages + # (e.g. Moonshot/Kimi K2.5). + pending_reasoning += reasoning_text # else: skip reasoning blocks for normal LLM messages elif item_type == 'open_webui:code_interpreter': @@ -906,10 +913,16 @@ async def cleanup_response( ): if response: if not response.closed: - await response.close() + # aiohttp 3.9+ made ClientResponse.close() synchronous (returns None). + # Older versions returned a coroutine. Handle both gracefully. + result = response.close() + if result is not None: + await result if session: if not session.closed: - await session.close() + result = session.close() + if result is not None: + await result async def stream_wrapper(response, session, content_handler=None): diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index c8ebc190e5..6b12515ba1 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -22,7 +22,7 @@ from open_webui.utils.plugin import ( load_function_module_by_id, get_function_module_from_cache, ) -from open_webui.utils.access_control import has_access +from open_webui.utils.access_control import has_access, has_base_model_access from open_webui.config import ( @@ -283,11 +283,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) # Pre-warm the function module cache once per unique function ID. # This ensures each function's DB freshness check runs exactly once, # not once per (model × function) pair. - for function_id in all_function_ids: + # Only attempt to load functions that actually exist in the local DB; + # imported/custom model configs may reference tools or filters the user + # hasn't installed, and trying to load those would cause persistent + # "Failed to load function module" log spam on every model refresh. + for function_id in functions_by_id: try: await get_function_module_from_cache(request, function_id) except Exception as e: - log.info(f'Failed to load function module for {function_id}: {e}') + log.debug(f'Failed to load function module for {function_id}: {e}') # Apply global model defaults to all models # Per-model overrides take precedence over global defaults @@ -404,6 +408,10 @@ async def check_model_access(user, model, db=None): ): raise Exception('Model not found') + # Enforce access on chained base models + if not await has_base_model_access(user.id, model_info, db=db): + raise Exception('Model not found') + async def get_filtered_models(models, user, db=None): # Filter out models that the user does not have access to diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 6020100eb4..9a35e30c3f 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -140,6 +140,41 @@ auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE +# Conservative default when the provider omits both expires_in and expires_at. +# Matches the value recommended by Authlib's compliance_fix documentation. +DEFAULT_TOKEN_EXPIRY_SECONDS = 3600 + + +def _normalize_token_expiry(token: dict) -> dict: + """Ensure a token dict always has a numeric ``expires_at``. + + Resolution order: + 1. If *expires_at* is already present and non-None, trust it. + 2. Else if *expires_in* is present and non-None, compute *expires_at*. + 3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a + warning so operators can identify providers that omit expiration. + + Also stamps *issued_at* for auditing. + """ + token['issued_at'] = datetime.now().timestamp() + + if token.get('expires_at') is not None: + token['expires_at'] = int(token['expires_at']) + return token + + if token.get('expires_in') is not None: + token['expires_at'] = int(datetime.now().timestamp() + token['expires_in']) + return token + + # Neither field present — conservative fallback + log.warning( + "OAuth token response missing both 'expires_in' and 'expires_at'; " + f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now' + ) + token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS) + return token + + FERNET = None if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44: @@ -478,10 +513,12 @@ async def get_oauth_client_info_with_static_credentials( log.error(f'Error parsing OAuth metadata from {url}: {e}') continue - # Determine scope from server metadata if available + # Let the OAuth provider apply its default scopes. + # We intentionally do NOT join all scopes_supported here — that list + # represents every scope the server *can* grant, not what the client + # should request. Requesting all of them is almost always wrong and + # can break providers like Entra ID that require resource-specific scopes. scope = None - if oauth_server_metadata and oauth_server_metadata.scopes_supported: - scope = ' '.join(oauth_server_metadata.scopes_supported) # Determine token_endpoint_auth_method token_endpoint_auth_method = 'client_secret_post' @@ -513,6 +550,24 @@ async def get_oauth_client_info_with_static_credentials( raise e +def resolve_oauth_client_info(connection: dict) -> dict: + """ + Decrypt OAuth client info from a tool server connection config. + + For oauth_2.1_static, overlays admin-provided credentials from + info.oauth_client_id and info.oauth_client_secret onto the blob. + """ + info = connection.get('info', {}) + data = decrypt_data(info.get('oauth_client_info', '')) + + if connection.get('auth_type') == 'oauth_2.1_static': + if info.get('oauth_client_id') and info.get('oauth_client_secret'): + data['client_id'] = info['oauth_client_id'] + data['client_secret'] = info['oauth_client_secret'] + + return data + + class OAuthClientManager: def __init__(self, app): self.oauth = OAuth() @@ -536,15 +591,20 @@ class OAuthClientManager: 'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None), } - if oauth_client_info.server_metadata and oauth_client_info.server_metadata.code_challenge_methods_supported: - if ( - isinstance( - oauth_client_info.server_metadata.code_challenge_methods_supported, - list, - ) - and 'S256' in oauth_client_info.server_metadata.code_challenge_methods_supported - ): - kwargs['code_challenge_method'] = 'S256' + # Default to S256 for OAuth 2.1 (PKCE is mandatory per RFC 9700) + kwargs['code_challenge_method'] = 'S256' + + # Only remove PKCE if metadata explicitly excludes S256 + if ( + oauth_client_info.server_metadata + and oauth_client_info.server_metadata.code_challenge_methods_supported + and isinstance( + oauth_client_info.server_metadata.code_challenge_methods_supported, + list, + ) + and 'S256' not in oauth_client_info.server_metadata.code_challenge_methods_supported + ): + del kwargs['code_challenge_method'] self.clients[client_id] = { 'client': self.oauth.register(**kwargs), @@ -584,7 +644,7 @@ class OAuthClientManager: continue try: - oauth_client_info = decrypt_data(oauth_client_info) + oauth_client_info = resolve_oauth_client_info(connection) return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client'] except Exception as e: log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}') @@ -707,7 +767,11 @@ class OAuthClientManager: log.warning(f'No OAuth session found for user {user_id}, client_id {client_id}') return None - if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + if ( + force_refresh + or session.expires_at is None + or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) + ): log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: @@ -818,14 +882,7 @@ class OAuthClientManager: if 'refresh_token' not in new_token_data: new_token_data['refresh_token'] = token_data['refresh_token'] - # Add timestamp for tracking - new_token_data['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: - new_token_data['expires_at'] = int( - datetime.now().timestamp() + new_token_data['expires_in'] - ) + _normalize_token_expiry(new_token_data) log.debug(f'Token refresh successful for client_id {client_id}') return new_token_data @@ -878,12 +935,7 @@ class OAuthClientManager: if token: try: - # Add timestamp for tracking - token['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in token and 'expires_at' not in token: - token['expires_at'] = datetime.now().timestamp() + token['expires_in'] + _normalize_token_expiry(token) # Clean up any existing sessions for this user/client_id first sessions = await OAuthSessions.get_sessions_by_user_id(user_id) @@ -970,7 +1022,11 @@ class OAuthManager: log.warning(f'No OAuth session found for user {user_id}, session {session_id}') return None - if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + if ( + force_refresh + or session.expires_at is None + or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) + ): log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: @@ -1084,14 +1140,7 @@ class OAuthManager: if 'refresh_token' not in new_token_data: new_token_data['refresh_token'] = token_data['refresh_token'] - # Add timestamp for tracking - new_token_data['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: - new_token_data['expires_at'] = int( - datetime.now().timestamp() + new_token_data['expires_in'] - ) + _normalize_token_expiry(new_token_data) log.debug(f'Token refresh successful for provider {provider}') return new_token_data @@ -1689,12 +1738,7 @@ class OAuthManager: ) try: - # Add timestamp for tracking - token['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in token and 'expires_at' not in token: - token['expires_at'] = datetime.now().timestamp() + token['expires_in'] + _normalize_token_expiry(token) # Enforce max concurrent sessions per user/provider to prevent # unbounded growth while allowing multi-device usage diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 440927caf1..7cf4fe4de3 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -204,6 +204,11 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: # Initialize the new message structure with the role new_message = {'role': message['role']} + # Preserve Ollama-native 'thinking' field (used by reasoning models, + # may be injected by filter inlet functions). + if 'thinking' in message: + new_message['thinking'] = message['thinking'] + content = message.get('content', []) tool_calls = message.get('tool_calls', None) tool_call_id = message.get('tool_call_id', None) diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py index e91579f4af..d74eae4f04 100644 --- a/backend/open_webui/utils/session_pool.py +++ b/backend/open_webui/utils/session_pool.py @@ -93,10 +93,16 @@ async def cleanup_response( """ if response: if not response.closed: - await response.close() + # aiohttp 3.9+ made ClientResponse.close() synchronous (returns None). + # Older versions returned a coroutine. Handle both gracefully. + result = response.close() + if result is not None: + await result if session: if not session.closed: - await session.close() + result = session.close() + if result is not None: + await result async def stream_wrapper(response, session=None, content_handler=None): diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index b48006db20..b7dfd69ffd 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -13,15 +13,15 @@ cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.9 +authlib==1.6.10 -requests==2.32.5 -aiohttp==3.13.2 # do not update to 3.13.3 - broken +requests==2.33.1 +aiohttp==3.13.5 # do not update to 3.13.3 - broken async-timeout aiocache aiofiles starlette-compress==1.7.0 -Brotli==1.1.0 +Brotli==1.2.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 @@ -52,7 +52,7 @@ langchain-text-splitters==1.1.1 fake-useragent==2.2.0 chromadb==1.5.2 -black==26.1.0 +black==26.3.1 pydub chardet==5.2.0 beautifulsoup4 diff --git a/backend/requirements.txt b/backend/requirements.txt index 25265d0631..9aaa3aad5d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,15 +10,15 @@ cryptography==46.0.5 bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.9 +authlib==1.6.10 -requests==2.32.5 -aiohttp==3.13.2 # do not update to 3.13.3 - broken +requests==2.33.1 +aiohttp==3.13.5 # do not update to 3.13.3 - broken async-timeout==5.0.1 aiocache==0.12.3 aiofiles==25.1.0 starlette-compress==1.7.0 -Brotli==1.1.0 +Brotli==1.2.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 @@ -58,8 +58,8 @@ chromadb==1.5.2 weaviate-client==4.20.3 opensearch-py==3.1.0 -transformers==5.3.0 -sentence-transformers==5.2.3 +transformers==5.5.4 +sentence-transformers==5.4.0 accelerate==1.13.0 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.2 @@ -95,7 +95,7 @@ rank-bm25==0.2.2 onnxruntime==1.24.3 faster-whisper==1.2.1 -black==26.1.0 +black==26.3.1 youtube-transcript-api==1.2.4 pytube==15.0.0 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index 5b37c8f8c8..0000000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,88 +0,0 @@ -# Contributing to Open WebUI - -🚀 **Welcome, Contributors!** 🚀 - -Your interest in contributing to Open WebUI is greatly appreciated. This document is here to guide you through the process, ensuring your contributions enhance the project effectively. Let's make Open WebUI even better, together! - -## 📌 Key Points - -### 🦙 Ollama vs. Open WebUI - -It's crucial to distinguish between Ollama and Open WebUI: - -- **Open WebUI** focuses on providing an intuitive and responsive web interface for chat interactions. -- **Ollama** is the underlying technology that powers these interactions. - -If your issue or contribution pertains directly to the core Ollama technology, please direct it to the appropriate [Ollama project repository](https://ollama.com/). Open WebUI's repository is dedicated to the web interface aspect only. - -### 🚨 Reporting Issues - -Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently. - -> [!IMPORTANT] -> -> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking. -> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue. - -> [!WARNING] -> Reporting vulnerabilities is not wanted through Issues! -> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements. - -### 🧭 Scope of Support - -We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience. - -- **Docker Deployment Support**: Open WebUI supports Docker deployment. Familiarity with Docker is assumed. For Docker basics, please refer to the [official Docker documentation](https://docs.docker.com/get-started/overview/). - -- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects. - -- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). - -## 💡 Contributing - -Looking to contribute? Great! Here's how you can help: - -### 🛠 Pull Requests - -We welcome pull requests. Before submitting one, please: - -1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose). -2. Follow the project's coding standards and include tests for new features. -3. Update documentation as necessary. -4. Write clear, descriptive commit messages. -5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward. - -> [!NOTE] -> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!). - -### 📚 Documentation & Tutorials - -Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI. - -Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). -We welcome tutorials, guides and other documentation improvements! - -### 🌐 Translations and Internationalization - -Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project. - -We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language. - -To add a new language: - -- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`. -- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object. -- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`. - -> [!NOTE] -> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow. - -### 🤔 Questions & Feedback - -Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help! - -## 🙏 Thank You! - -Your contributions, big or small, make a significant impact on Open WebUI. We're excited to see what you bring to the project! - -Together, let's create an even more powerful tool for the community. 🌟 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4113c35a78..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Project workflow - -[![](https://mermaid.ink/img/pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg?type=png)](https://mermaid.live/edit#pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg) diff --git a/docs/apache.md b/docs/apache.md deleted file mode 100644 index bdf119b5b6..0000000000 --- a/docs/apache.md +++ /dev/null @@ -1,205 +0,0 @@ -# Hosting UI and Models separately - -Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: - -# Open WebUI Configuration - -## UI Configuration - -For the UI configuration, you can set up the Apache VirtualHost as follows: - -``` -# Assuming you have a website hosting this UI at "server.com" - - ServerName server.com - DocumentRoot /home/server/public_html - - ProxyPass / http://server.com:3000/ nocanon - ProxyPassReverse / http://server.com:3000/ - # Needed after 0.5 - ProxyPass / ws://server.com:3000/ nocanon - ProxyPassReverse / ws://server.com:3000/ - - -``` - -Enable the site first before you can request SSL: - -`a2ensite server.com.conf` # this will enable the site. a2ensite is short for "Apache 2 Enable Site" - -``` -# For SSL - - ServerName server.com - DocumentRoot /home/server/public_html - - ProxyPass / http://server.com:3000/ nocanon - ProxyPassReverse / http://server.com:3000/ - # Needed after 0.5 - ProxyPass / ws://server.com:3000/ nocanon - ProxyPassReverse / ws://server.com:3000/ - - SSLEngine on - SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert - SSLCertificateKeyFile /etc/ssl/virtualmin/170514456861234/ssl.key - SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1 - - SSLProxyEngine on - SSLCACertificateFile /etc/ssl/virtualmin/170514456865864/ssl.ca - - -``` - -I'm using virtualmin here for my SSL clusters, but you can also use certbot directly or your preferred SSL method. To use SSL: - -### Prerequisites. - -Run the following commands: - -`snap install certbot --classic` -`snap apt install python3-certbot-apache` (this will install the apache plugin). - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` - -Create server.com.conf if it is not yet already created, containing the above `` configuration (it should match your case. Modify as necessary). Use the one without the SSL: - -Once it's created, run `certbot --apache -d server.com`, this will request and add/create an SSL keys for you as well as create the server.com.le-ssl.conf - -# Configuring Ollama Server - -On your latest installation of Ollama, make sure that you have setup your api server from the official Ollama reference: - -[Ollama FAQ](https://github.com/jmorganca/ollama/blob/main/docs/faq.md) - -### TL;DR - -The guide doesn't seem to match the current updated service file on linux. So, we will address it here: - -Unless when you're compiling Ollama from source, installing with the standard install `curl https://ollama.com/install.sh | sh` creates a file called `ollama.service` in /etc/systemd/system. You can use nano to edit the file: - -``` -sudo nano /etc/systemd/system/ollama.service -``` - -Add the following lines: - -``` -Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify -``` - -For instance: - -``` -[Unit] -Description=Ollama Service -After=network-online.target - -[Service] -ExecStart=/usr/local/bin/ollama serve -Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify 192.168.254.109:DIFFERENT_PORT, format -Environment="OLLAMA_ORIGINS=http://192.168.254.106:11434,https://models.server.city" # this line is optional -User=ollama -Group=ollama -Restart=always -RestartSec=3 -Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/s> - -[Install] -WantedBy=default.target -``` - -Save the file by pressing CTRL+S, then press CTRL+X - -When your computer restarts, the Ollama server will now be listening on the IP:PORT you specified, in this case 0.0.0.0:11434, or 192.168.254.106:11434 (whatever your local IP address is). Make sure that your router is correctly configured to serve pages from that local IP by forwarding 11434 to your local IP server. - -# Ollama Model Configuration - -## For the Ollama model configuration, use the following Apache VirtualHost setup: - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` - -`nano models.server.city.conf` # match this with your ollama server domain - -Add the following virtualhost containing this example (modify as needed): - -``` - -# Assuming you have a website hosting this UI at "models.server.city" - - - DocumentRoot "/var/www/html/" - ServerName models.server.city - - Options None - Require all granted - - - ProxyRequests Off - ProxyPreserveHost On - ProxyAddHeaders On - SSLProxyEngine on - - ProxyPass / http://server.city:1000/ nocanon # or port 11434 - ProxyPassReverse / http://server.city:1000/ # or port 11434 - - SSLCertificateFile /etc/letsencrypt/live/models.server.city/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/models.server.city/privkey.pem - Include /etc/letsencrypt/options-ssl-apache.conf - - -``` - -You may need to enable the site first (if you haven't done so yet) before you can request SSL: - -`a2ensite models.server.city.conf` - -#### For the SSL part of Ollama server - -Run the following commands: - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` -`certbot --apache -d server.com` - -``` - - DocumentRoot "/var/www/html/" - ServerName models.server.city - - Options None - Require all granted - - - ProxyRequests Off - ProxyPreserveHost On - ProxyAddHeaders On - SSLProxyEngine on - - ProxyPass / http://server.city:1000/ nocanon # or port 11434 - ProxyPassReverse / http://server.city:1000/ # or port 11434 - - RewriteEngine on - RewriteCond %{SERVER_NAME} =models.server.city - RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] - - -``` - -Don't forget to restart/reload Apache with `systemctl reload apache2` - -Open your site at https://server.com! - -**Congratulations**, your _**Open-AI-like Chat-GPT style UI**_ is now serving AI with RAG, RBAC and multimodal features! Download Ollama models if you haven't yet done so! - -If you encounter any misconfiguration or errors, please file an issue or engage with our discussion. There are a lot of friendly developers here to assist you. - -Let's make this UI much more user friendly for everyone! - -Thanks for making open-webui your UI Choice for AI! - -This doc is made by **Bob Reyes**, your **Open-WebUI** fan from the Philippines. diff --git a/package-lock.json b/package-lock.json index 538792470e..8efa79c1b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -54,6 +54,7 @@ "dayjs": "^1.11.10", "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", + "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", @@ -214,42 +215,40 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", - "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.1.2", - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" } }, "node_modules/@chevrotain/gast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", - "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" + "@chevrotain/types": "12.0.0" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", - "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", - "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", - "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, "node_modules/@codemirror/autocomplete": { @@ -1247,9 +1246,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1373,9 +1372,9 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3583,9 +3582,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.55.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", - "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "version": "2.57.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", + "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -3611,7 +3610,7 @@ "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", + "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -5389,9 +5388,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5619,9 +5618,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5963,9 +5962,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6368,29 +6367,31 @@ } }, "node_modules/chevrotain": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", - "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.2", - "@chevrotain/gast": "11.1.2", - "@chevrotain/regexp-to-ast": "11.1.2", - "@chevrotain/types": "11.1.2", - "@chevrotain/utils": "11.1.2", - "lodash-es": "4.17.23" + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", + "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { - "chevrotain": "^11.0.0" + "chevrotain": "^12.0.0" } }, "node_modules/chokidar": { @@ -7743,9 +7744,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8238,9 +8239,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -8462,7 +8463,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -9051,9 +9051,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10152,13 +10152,14 @@ } }, "node_modules/langium": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", - "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", + "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.1.1", - "chevrotain-allstar": "~0.3.1", + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" @@ -10600,16 +10601,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -10870,9 +10871,9 @@ "license": "MIT" }, "node_modules/matcher-collection/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11103,9 +11104,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -11765,9 +11766,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -11914,9 +11915,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -12326,9 +12327,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -12460,9 +12461,9 @@ "license": "MIT" }, "node_modules/quick-temp/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -12742,9 +12743,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -15408,9 +15409,9 @@ } }, "node_modules/vite-plugin-static-copy/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -16152,9 +16153,9 @@ "license": "MIT" }, "node_modules/walk-sync/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -16441,9 +16442,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index ab0cf78afc..bc1a1c5da3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.12", + "version": "0.9.0", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -98,6 +98,7 @@ "dayjs": "^1.11.10", "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", + "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", diff --git a/pyproject.toml b/pyproject.toml index 7a546e935f..27e6faeddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,10 @@ dependencies = [ "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.11.0", - "authlib==1.6.9", + "authlib==1.6.10", - "requests==2.32.5", - "aiohttp==3.13.2", # do not update to 3.13.3 - broken + "requests==2.33.1", + "aiohttp==3.13.5", # do not update to 3.13.3 - broken "async-timeout==5.0.1", "aiocache==0.12.3", "aiofiles==25.1.0", @@ -64,8 +64,8 @@ dependencies = [ "PyMySQL==1.1.2", "boto3==1.42.62", - "transformers==5.3.0", - "sentence-transformers==5.2.3", + "transformers==5.5.4", + "sentence-transformers==5.4.0", "accelerate==1.13.0", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.2", @@ -100,7 +100,7 @@ dependencies = [ "onnxruntime==1.24.3", "faster-whisper==1.2.1", - "black==26.1.0", + "black==26.3.1", "youtube-transcript-api==1.2.4", "pytube==15.0.0", diff --git a/src/app.css b/src/app.css index 93417b680a..ac02afcb1d 100644 --- a/src/app.css +++ b/src/app.css @@ -260,6 +260,12 @@ select { display: none; } +/* Hide leaked Mermaid temp containers if render cleanup misses */ +body > div[id^='dmermaid-'], +body > iframe[id^='imermaid-'] { + display: none !important; +} + .scrollbar-hidden:active::-webkit-scrollbar-thumb, .scrollbar-hidden:focus::-webkit-scrollbar-thumb, .scrollbar-hidden:hover::-webkit-scrollbar-thumb { diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index e16746707a..028371386c 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -953,6 +953,73 @@ export const deleteSharedChatById = async (token: string, id: string) => { return res; }; +export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getChatAccessGrants = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, { + 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(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateChatById = async (token: string, id: string, chat: object) => { let error = null; diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 07e249a889..80f0413bbc 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -313,3 +313,65 @@ export const deleteNoteById = async (token: string, id: string) => { return res; }; + +export const getPinnedNoteList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/pinned`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res ?? []; +}; + +export const toggleNotePinnedStatusById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pin`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index d146aeca0c..74571c3086 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -78,21 +78,20 @@ return; } + if (auth_type === 'oauth_2.1_static' && (!oauthClientId || !oauthClientSecret)) { + toast.error($i18n.t('Please enter Client ID and Client Secret')); + return; + } + + // client_id is the tool server ID (used as the internal lookup key for both flows). + // For static, client_secret signals the backend to use the static credential path. + // The actual OAuth client_id/secret come from the connection info at save time. const formData: { url: string; client_id: string; client_secret?: string } = { url: url, - client_id: id + client_id: id, + ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {}) }; - // For static OAuth, include client credentials - if (auth_type === 'oauth_2.1_static') { - if (!oauthClientId || !oauthClientSecret) { - toast.error($i18n.t('Please enter Client ID and Client Secret')); - return; - } - formData.client_id = id; - formData.client_secret = oauthClientSecret; - } - const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => { toast.error($i18n.t('Registration failed')); return null; diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index ece64afd54..eeb6b18b10 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -1185,6 +1185,23 @@ {/if} +
+
+ {$i18n.t('Reranking Batch Size')} +
+ +
+ +
+
+
{$i18n.t('Top K')}
diff --git a/src/lib/components/automations/ScheduleDropdown.svelte b/src/lib/components/automations/ScheduleDropdown.svelte index 67f4fbce69..4f7b63a541 100644 --- a/src/lib/components/automations/ScheduleDropdown.svelte +++ b/src/lib/components/automations/ScheduleDropdown.svelte @@ -1,9 +1,10 @@ diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index ec48a2e1ff..3b26dba1ff 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -7,6 +7,7 @@ import { user as _user } from '$lib/stores'; import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; + import equal from 'fast-deep-equal'; import Name from './Name.svelte'; import ProfileImage from './ProfileImage.svelte'; @@ -58,7 +59,7 @@ if (source) { if (message.content !== source.content) { message = structuredClone(source); - } else if (JSON.stringify(message) !== JSON.stringify(source)) { + } else if (!equal(message, source)) { message = structuredClone(source); } } diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index bc228e1d44..1677133d37 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -450,6 +450,7 @@ }); if (res) { + // $i18n.t('Model {{modelId}} not found') toast.success( $i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id }) ); diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index e0dc1407f2..7f88f1dee7 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -248,7 +248,7 @@
- {#if $config?.features.enable_login_form} + {#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte index 1c02f6481d..7aa82df34c 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -3,24 +3,32 @@ import { models, config } from '$lib/stores'; import { toast } from 'svelte-sonner'; - import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats'; + import { + deleteSharedChatById, + getChatById, + shareChatById, + getChatAccessGrants, + updateChatAccessGrants + } from '$lib/apis/chats'; import { copyToClipboard } from '$lib/utils'; import Modal from '../common/Modal.svelte'; import Link from '../icons/Link.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; + import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; export let chatId; let chat = null; let shareUrl = null; + let accessGrants: any[] = []; const i18n = getContext('i18n'); const shareLocalChat = async () => { const _chat = chat; const sharedChat = await shareChatById(localStorage.token, chatId); - shareUrl = `${window.location.origin}/s/${sharedChat.id}`; + shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`; console.log(shareUrl); chat = await getChatById(localStorage.token, chatId); @@ -54,6 +62,25 @@ ); }; + const loadAccessGrants = async () => { + if (!chatId) return; + try { + accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? []; + } catch (e) { + console.error('Failed to load access grants', e); + accessGrants = []; + } + }; + + const saveAccessGrants = async () => { + try { + await updateChatAccessGrants(localStorage.token, chatId, accessGrants); + toast.success($i18n.t('Access updated')); + } catch (e) { + toast.error(`${e}`); + } + }; + export let show = false; const isDifferentChat = (_chat) => { @@ -73,8 +100,10 @@ if (isDifferentChat(_chat)) { chat = _chat; } + await loadAccessGrants(); } else { chat = null; + accessGrants = []; console.log(chat); } })(); @@ -97,8 +126,8 @@
{#if chat} -
-
+
+ -
-
-
- {#if $config?.features.enable_community_sharing} - - {/if} - - -
+ {#if chat.share_id} +
+
+ {/if} + +
+ {#if $config?.features.enable_community_sharing} + + {/if} + +
{/if} diff --git a/src/lib/components/common/Drawer.svelte b/src/lib/components/common/Drawer.svelte index 198f21e2fa..8a52da2b92 100644 --- a/src/lib/components/common/Drawer.svelte +++ b/src/lib/components/common/Drawer.svelte @@ -41,6 +41,7 @@ } onDestroy(() => { + window.removeEventListener('keydown', handleKeyDown); show = false; if (modalElement) { if (document.body.contains(modalElement)) { diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 4cdd8878f1..673e651c79 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -36,6 +36,18 @@ }); turndownService.escape = (string) => string; + // Produce single newlines between paragraphs instead of double. + // TipTap wraps every line in

tags; the default Turndown rule emits + // \n\n around each paragraph which then required a destructive + // replaceAll('\n\n','\n') that also wiped blank lines inside code blocks. + // This rule eliminates that hack so

 content is untouched.
+	turndownService.addRule('singleNewlineParagraphs', {
+		filter: 'p',
+		replacement: function (content) {
+			return '\n' + content + '\n';
+		}
+	});
+
 	// Use turndown-plugin-gfm for proper GFM table support
 	turndownService.use(gfm);
 
@@ -435,7 +447,6 @@
 
 	export const setText = (text: string) => {
 		if (!editor || !editor.view) return;
-		text = text.replaceAll('\n\n', '\n');
 
 		if (text === '') {
 			editor.commands.clearContent();
diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte
index 1bead4b4dd..a21626edea 100644
--- a/src/lib/components/common/ToolCallDisplay.svelte
+++ b/src/lib/components/common/ToolCallDisplay.svelte
@@ -77,7 +77,9 @@
 	}
 
 	$: args = decode(attributes?.arguments ?? '');
-	$: result = decode(attributes?.result ?? '');
+	export let resultContent: string = '';
+
+	$: result = resultContent || decode(attributes?.result ?? '');
 	$: files = parseJSONString(decode(attributes?.files ?? ''));
 	$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
 	$: isDone = attributes?.done === 'true';
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
index e28229989e..3125d3d5e4 100644
--- a/src/lib/components/layout/Sidebar.svelte
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -16,6 +16,7 @@
 		mobile,
 		showArchivedChats,
 		pinnedChats,
+		pinnedNotes,
 		scrollPaginationEnabled,
 		currentChatPage,
 		temporaryChatEnabled,
@@ -44,6 +45,8 @@
 	} from '$lib/apis/chats';
 	import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
 	import { checkActiveChats } from '$lib/apis/tasks';
+	import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
+	import { createNoteHandler } from '$lib/components/notes/utils';
 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
 
 	import ArchivedChatsModal from './ArchivedChatsModal.svelte';
@@ -86,6 +89,7 @@
 	let pinnedModels = [];
 
 	let showPinnedModels = false;
+	let showPinnedNotes = false;
 	let showChannels = false;
 	let showFolders = false;
 
@@ -227,6 +231,16 @@
 				const _pinnedChats = await getPinnedChatList(localStorage.token);
 				pinnedChats.set(_pinnedChats);
 			})(),
+			await (async () => {
+				if (
+					$config?.features?.enable_notes &&
+					($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
+				) {
+					console.log('Init pinned notes');
+					const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
+					pinnedNotes.set(_pinnedNotes);
+				}
+			})(),
 			await (async () => {
 				console.log('Init chat list');
 				const _chats = await getChatList(localStorage.token, $currentChatPage);
@@ -1072,6 +1086,70 @@
 					
 				{/if}
 
+				{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
+					 {
+							const note = await createNoteHandler('New Note');
+							if (note) {
+								goto(`/notes/${note.id}`);
+							}
+						}}
+						onAddLabel={$i18n.t('New Note')}
+					>
+						
+					
+				{/if}
+
 				{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
 					 lastReadAt));
+		(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
 
 	const loadChat = async () => {
 		if (!chat) {
diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte
index 05d2c1b989..8f387ed6ed 100644
--- a/src/lib/components/notes/NoteEditor.svelte
+++ b/src/lib/components/notes/NoteEditor.svelte
@@ -35,7 +35,8 @@
 		showSidebar,
 		socket,
 		user,
-		WEBUI_NAME
+		WEBUI_NAME,
+		pinnedNotes
 	} from '$lib/stores';
 
 	import { downloadPdf } from './utils';
@@ -64,7 +65,9 @@
 		deleteNoteById,
 		getNoteById,
 		updateNoteById,
-		updateNoteAccessGrants
+		updateNoteAccessGrants,
+		toggleNotePinnedStatusById,
+		getPinnedNoteList
 	} from '$lib/apis/notes';
 
 	import RichTextInput from '../common/RichTextInput.svelte';
@@ -1088,6 +1091,12 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
 									onDelete={() => {
 										showDeleteConfirm = true;
 									}}
+									isPinned={note.is_pinned ?? false}
+									onPin={async () => {
+										await toggleNotePinnedStatusById(localStorage.token, note.id);
+										note = await getNoteById(localStorage.token, note.id);
+										pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => []));
+									}}
 								>
 									
diff --git a/src/lib/components/notes/NoteEditor/Chat.svelte b/src/lib/components/notes/NoteEditor/Chat.svelte index 2430f602e1..80a54adc38 100644 --- a/src/lib/components/notes/NoteEditor/Chat.svelte +++ b/src/lib/components/notes/NoteEditor/Chat.svelte @@ -169,13 +169,18 @@ Based on the user's instruction, update and enhance the existing notes or select : '') + (selectedContent ? `\n${selectedContent?.text}` : ''); + // Filter out empty assistant placeholder messages to avoid sending + // an empty trailing assistant message as "response prefill", which is + // incompatible with enable_thinking in llama.cpp and similar backends. + const filteredMessages = messages.filter((m) => !(m.role === 'assistant' && m.content === '')); + const chatMessages = JSON.parse( JSON.stringify([ { role: 'system', content: `${system}` }, - ...messages + ...filteredMessages ]) ); diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 4b4a208a83..e7762cf258 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -30,13 +30,15 @@ $: loadLocale($i18n.languages); import { goto } from '$app/navigation'; - import { WEBUI_NAME, config, user } from '$lib/stores'; + import { WEBUI_NAME, config, user, pinnedNotes } from '$lib/stores'; import { createNewNote, deleteNoteById, getNoteById, getNoteList, - searchNotes + searchNotes, + toggleNotePinnedStatusById, + getPinnedNoteList } from '$lib/apis/notes'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; import { downloadPdf, createNoteHandler } from './utils'; @@ -540,6 +542,14 @@ selectedNote = note; showDeleteConfirm = true; }} + isPinned={note.is_pinned ?? false} + onPin={async () => { + await toggleNotePinnedStatusById(localStorage.token, note.id); + pinnedNotes.set( + await getPinnedNoteList(localStorage.token).catch(() => []) + ); + init(); + }} > + {/if} +