From 51765b619c8584b042af68c3a5c87525a105ccd8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 13:13:45 -0500 Subject: [PATCH 001/101] refac --- backend/open_webui/main.py | 28 ++++++++-------- backend/open_webui/utils/middleware.py | 44 +++++++++++++++----------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 8c2a139dba..7606daf44c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1787,11 +1787,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: @@ -1812,15 +1813,16 @@ async def chat_completion( ) 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 diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 4d4a3726c3..90e0861d82 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -4692,25 +4692,33 @@ async def streaming_chat_response_handler(response, ctx): await background_tasks_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() From 22cfb3c673cbfa4a6bce26fde8e2e2754ce4963b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 13:26:13 -0500 Subject: [PATCH 002/101] refac --- backend/open_webui/routers/retrieval.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 1e1beac4fa..4214eb6851 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1698,7 +1698,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, From d1a0fbe29251d2ef52a4619decb08966758328e1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 13:36:54 -0500 Subject: [PATCH 003/101] refac --- backend/open_webui/routers/retrieval.py | 2 +- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 4214eb6851..417441305e 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -151,7 +151,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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 25265d0631..8934c67a7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -58,7 +58,7 @@ chromadb==1.5.2 weaviate-client==4.20.3 opensearch-py==3.1.0 -transformers==5.3.0 +transformers==5.5.4 sentence-transformers==5.2.3 accelerate==1.13.0 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 diff --git a/pyproject.toml b/pyproject.toml index 7a546e935f..3670bf1fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ "PyMySQL==1.1.2", "boto3==1.42.62", - "transformers==5.3.0", + "transformers==5.5.4", "sentence-transformers==5.2.3", "accelerate==1.13.0", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 From 8936721414a17832852a90f3ee592af5a8b7232d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 13:44:44 -0500 Subject: [PATCH 004/101] refac --- backend/open_webui/config.py | 80 ++++++++++++++++++++++++++- backend/open_webui/main.py | 3 +- backend/open_webui/routers/configs.py | 4 +- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index fe25dda1c1..4a583fdcd0 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") diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 7606daf44c..7fa328d879 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -470,6 +470,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, @@ -625,7 +626,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) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 041c4ad935..bdcf247230 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 ( @@ -49,7 +49,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() From 45f45f5bba0e7d19b6303fe2ac56b7d531073dd1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 13:49:28 -0500 Subject: [PATCH 005/101] chore: bump dep --- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8934c67a7b..2816e8195b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -59,7 +59,7 @@ weaviate-client==4.20.3 opensearch-py==3.1.0 transformers==5.5.4 -sentence-transformers==5.2.3 +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 diff --git a/pyproject.toml b/pyproject.toml index 3670bf1fb4..c7f14e2c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ "boto3==1.42.62", "transformers==5.5.4", - "sentence-transformers==5.2.3", + "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", From d0188f3fe1cd240a06e068acd1a3b906a23827e6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:08:58 -0500 Subject: [PATCH 006/101] refac --- backend/open_webui/constants.py | 13 ++++++++ backend/open_webui/routers/auths.py | 6 ++-- backend/open_webui/routers/automations.py | 4 +-- backend/open_webui/routers/channels.py | 6 ++-- backend/open_webui/routers/functions.py | 2 +- backend/open_webui/routers/memories.py | 2 +- backend/open_webui/routers/ollama.py | 36 +++++++++++------------ backend/open_webui/routers/openai.py | 14 ++++----- backend/open_webui/routers/prompts.py | 6 ++-- backend/open_webui/routers/tasks.py | 26 ++++++++-------- backend/open_webui/routers/tools.py | 2 +- backend/open_webui/routers/utils.py | 4 +-- backend/open_webui/utils/automations.py | 5 ++-- 13 files changed, 70 insertions(+), 56 deletions(-) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index c0c79fdf50..f7a3e6f664 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -91,6 +91,19 @@ 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/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/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/memories.py b/backend/open_webui/routers/memories.py index 3f6c080ad6..cfd8274812 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -268,7 +268,7 @@ 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) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 0c6fe73abf..d06ceee6ec 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) @@ -1067,7 +1067,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 +1313,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 +1371,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 @@ -1396,13 +1396,13 @@ async def generate_responses( ): raise HTTPException( status_code=403, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) else: if user.role != 'admin': raise HTTPException( status_code=403, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) url, url_idx = await get_ollama_url(request, payload['model'], url_idx) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 09db29e6d6..652c910fea 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -691,7 +691,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 +718,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 +1083,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 +1243,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 +1321,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 +1445,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/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/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/utils/automations.py b/backend/open_webui/utils/automations.py index 5997b0e58b..a32b04bbfb 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]: From 050c4b97a95addc5eaeef86ba00631673a90dec4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:13:03 -0500 Subject: [PATCH 007/101] refac --- backend/open_webui/utils/oauth.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 6020100eb4..7fe8d54690 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -536,15 +536,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), From 96265cf042c8ab97dbec5d0efcce8010d0cd76e5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:19:15 -0500 Subject: [PATCH 008/101] refac --- backend/open_webui/main.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 7fa328d879..0bb731188b 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1767,6 +1767,21 @@ async def chat_completion( form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) response = await chat_completion_handler(request, form_data, user) + + # 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: + 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: + detail = f'Provider returned HTTP {response.status_code}' + raise Exception(detail) if metadata.get('chat_id') and metadata.get('message_id'): try: if not metadata['chat_id'].startswith('local:'): From 2ddcb30b9a519885422ba1f36cc3485a7d897bf8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:29:27 -0500 Subject: [PATCH 009/101] refac --- backend/open_webui/main.py | 11 +- backend/open_webui/routers/images.py | 150 +++++++++++++++------------ backend/open_webui/routers/openai.py | 13 +-- backend/open_webui/utils/files.py | 16 +-- 4 files changed, 106 insertions(+), 84 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 0bb731188b..fce771208f 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 @@ -60,6 +60,7 @@ from starsessions.stores.redis import RedisStore from open_webui.utils import logger 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, @@ -2512,7 +2513,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/routers/images.py b/backend/open_webui/routers/images.py index 0d534db7f6..832022e92d 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Optional from urllib.parse import quote +import aiohttp import requests 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 @@ -313,12 +315,14 @@ 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) @@ -327,12 +331,14 @@ async def verify_url(request: Request, user=Depends(get_admin_user)): 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) @@ -357,11 +363,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 +407,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']}, @@ -533,7 +543,7 @@ async def image_generations( model = get_image_model(request) - r = None + try: if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': headers = { @@ -568,16 +578,15 @@ 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 = [] @@ -619,16 +628,15 @@ 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 = [] @@ -727,15 +735,14 @@ 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 = [] @@ -753,10 +760,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 +803,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 +852,7 @@ async def image_edits( ), ) - r = None + try: if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai': headers = { @@ -883,17 +889,30 @@ 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']: @@ -940,16 +959,15 @@ 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']: @@ -1048,13 +1066,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/openai.py b/backend/open_webui/routers/openai.py index 652c910fea..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', ) diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 21e3af5752..9eec22a5c3 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -25,7 +25,8 @@ 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,12 +38,13 @@ 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) From 869cf9e848b741705dc058550fa1b3f70db47fe8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:33:23 -0500 Subject: [PATCH 010/101] refac --- backend/open_webui/routers/images.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 832022e92d..f3e95e2db9 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -562,7 +562,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( From 40f5b3d135190dc9a2d8e94dbb1b2cbcbd829132 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 14:51:09 -0500 Subject: [PATCH 011/101] refac --- backend/open_webui/utils/anthropic.py | 132 ++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 5ba4099fb4..aebb96a3e1 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -181,17 +181,133 @@ 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( { From 9c64d84ad90804bf7d891e4a5097c03c4d7044c3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 15:03:22 -0500 Subject: [PATCH 012/101] refac --- backend/open_webui/retrieval/web/firecrawl.py | 40 +++++-- backend/open_webui/retrieval/web/utils.py | 110 +++++------------- 2 files changed, 58 insertions(+), 92 deletions(-) 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}') From 31406caa795173a59d5843d3601b891bf617cbaa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 15:13:14 -0500 Subject: [PATCH 013/101] refac --- backend/open_webui/models/oauth_sessions.py | 4 +- backend/open_webui/utils/oauth.py | 71 ++++++++++++--------- 2 files changed, 43 insertions(+), 32 deletions(-) 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/utils/oauth.py b/backend/open_webui/utils/oauth.py index 7fe8d54690..767e4db6a6 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: @@ -712,7 +747,7 @@ 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: @@ -823,14 +858,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 @@ -883,12 +911,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) @@ -975,7 +998,7 @@ 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: @@ -1089,14 +1112,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 @@ -1694,12 +1710,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 From 611fe0c8a938539b73b559e84964f40c30bf436d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 15:14:55 -0500 Subject: [PATCH 014/101] refac --- backend/open_webui/main.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index fce771208f..90d101d23b 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1424,15 +1424,14 @@ async def check_url(request: Request, call_next): request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=request.cookies.get('token')) - # Fallback to x-api-key header for Anthropic Messages API routes + # Fallback to x-api-key header (Anthropic-compatible clients use this + # for ALL requests, including GET /v1/models, not just POST /v1/messages). 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 + from fastapi.security import HTTPAuthorizationCredentials - request.state.token = HTTPAuthorizationCredentials( - scheme='Bearer', credentials=request.headers.get('x-api-key') - ) + 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) From 2991d9f1f0538d9f097b31e92afb65d4019a9384 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:57:12 -0400 Subject: [PATCH 015/101] fix(ui): automatically close channel input more menu dropdown dynamically on file interactions (#23684) --- src/lib/components/channel/MessageInput/InputMenu.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/channel/MessageInput/InputMenu.svelte b/src/lib/components/channel/MessageInput/InputMenu.svelte index e0a530aad9..09ad17f59f 100644 --- a/src/lib/components/channel/MessageInput/InputMenu.svelte +++ b/src/lib/components/channel/MessageInput/InputMenu.svelte @@ -51,6 +51,7 @@ type="button" on:click={() => { uploadFilesHandler(); + show = false; }} > @@ -62,6 +63,7 @@ type="button" on:click={() => { screenCaptureHandler(); + show = false; }} > From 026903399be73ac4b6c226647110e5662d043a50 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 15:58:33 -0500 Subject: [PATCH 016/101] refac --- src/lib/components/chat/Messages.svelte | 125 ++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index e8ada5c57c..feabd0ffa4 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -57,18 +57,119 @@ export let onSelect = (e) => {}; - export let messagesCount: number | null = 20; + export let messagesCount: number | null = 10; let messagesLoading = false; + // Off-screen message unloading. Heights are measured on scroll so spacers + // always match real sizes — no scroll jumps, no feedback loops needed. + const OVERSCAN = 3; + const DEFAULT_HEIGHT = 150; + let visibleStart = 0; + let visibleEnd = 0; + let messageHeights = new Map(); + let topSpacerHeight = 0; + let bottomSpacerHeight = 0; + let pendingCull = null; + + // Helper: get height for a message (cached or default) + const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT; + + /** Measure all currently rendered message elements and cache their heights */ + const measureMessageHeights = () => { + const elements = document.getElementById('messages-container')?.querySelectorAll('[role="listitem"]'); + if (!elements) return; + + messageHeights = new Map([ + ...messageHeights, + ...Array.from(elements) + .map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height]) + .filter(([id]) => id != null) + ]); + }; + + /** Compute visible range from current scroll position and apply */ + const updateVisibleRange = () => { + const container = document.getElementById('messages-container'); + if (!container || messages.length === 0) return; + + const st = container.scrollTop; + const ch = container.clientHeight; + + // Build prefix sums from measured heights + const prefixSums = messages.reduce( + (acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)], + [0] + ); + + const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1); + const lastVisible = prefixSums.findIndex((h) => h > st + ch); + + // Only cull messages that have been measured (so spacer height is accurate) + // findIndex returns -1 when all are measured → no limit on culling + const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id)); + const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured; + + visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit)); + visibleEnd = Math.min(messages.length, (lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN); + topSpacerHeight = prefixSums[visibleStart] ?? 0; + bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0); + }; + + /** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */ + const handleContainerScroll = () => { + measureMessageHeights(); + + // Don't cull during progressive loading + if (messagesLoading) return; + + if (!pendingCull) { + pendingCull = requestAnimationFrame(() => { + pendingCull = null; + updateVisibleRange(); + }); + } + }; + + let scrollListenerAttached = false; + + const attachScrollListener = () => { + if (scrollListenerAttached) return; + const container = document.getElementById('messages-container'); + if (!container) return; + + container.addEventListener('scroll', handleContainerScroll, { passive: true }); + scrollListenerAttached = true; + }; + + onMount(() => { + attachScrollListener(); + }); + + onDestroy(() => { + const container = document.getElementById('messages-container'); + if (container && scrollListenerAttached) { + container.removeEventListener('scroll', handleContainerScroll); + } + cancelAnimationFrame(pendingCull); + cancelAnimationFrame(pendingRebuild); + }); + const loadMoreMessages = async () => { // scroll slightly down to disable continuous loading const element = document.getElementById('messages-container'); element.scrollTop = element.scrollTop + 100; messagesLoading = true; - messagesCount += 20; + messagesCount += 10; + buildMessages(); + // Show all messages during progressive loading (no culling) + visibleStart = 0; + visibleEnd = messages.length; + topSpacerHeight = 0; + bottomSpacerHeight = 0; + await tick(); messagesLoading = false; @@ -95,6 +196,7 @@ } messages = _messages.reverse(); + visibleEnd = messages.length; }; // Throttle message list rebuilds to once per animation frame during streaming. @@ -113,6 +215,8 @@ cancelAnimationFrame(pendingRebuild); pendingRebuild = null; buildMessages(); + // No explicit culling needed — scrollToBottom will fire a scroll event, + // which triggers handleContainerScroll → rAF → updateVisibleRange } else if (_messages) { // Content update (streaming) — throttle to once per frame if (!pendingRebuild) { @@ -426,9 +530,7 @@ showMessage({ id: parentMessageId }, false); }; - onDestroy(() => { - cancelAnimationFrame(pendingRebuild); - }); + const triggerScroll = () => { if (autoScroll) { @@ -465,7 +567,13 @@ {/if}
    - {#each messages as message, messageIdx (message.id)} + + {#if topSpacerHeight > 0} +
From 9dccd29c94875e6f0ac373c5802cb183296e47ff Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 16:00:03 -0500 Subject: [PATCH 017/101] refac --- src/lib/components/chat/Messages.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index feabd0ffa4..2f0601a851 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -57,7 +57,7 @@ export let onSelect = (e) => {}; - export let messagesCount: number | null = 10; + export let messagesCount: number | null = 8; let messagesLoading = false; // Off-screen message unloading. Heights are measured on scroll so spacers @@ -160,7 +160,7 @@ element.scrollTop = element.scrollTop + 100; messagesLoading = true; - messagesCount += 10; + messagesCount += 8; buildMessages(); From 8dba798cce9fb1efc5f6acc5f37b152662db78d7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 16:03:36 -0500 Subject: [PATCH 018/101] refac --- backend/open_webui/retrieval/vector/dbs/pgvector.py | 13 +++++++++---- backend/open_webui/retrieval/vector/utils.py | 10 +++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) 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..3ee413eaf7 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,8 @@ 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 + From cd55c3e21237e000c13c6f396bb95b261f3bda82 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 16:03:51 -0500 Subject: [PATCH 019/101] refac --- .../Knowledge/KnowledgeBase/AddTextContentModal.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte index e402bc044a..fbf8b1289f 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/AddTextContentModal.svelte @@ -7,7 +7,6 @@ const dispatch = createEventDispatcher(); import Modal from '$lib/components/common/Modal.svelte'; - import RichTextInput from '$lib/components/common/RichTextInput.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import MicSolid from '$lib/components/icons/MicSolid.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; @@ -57,7 +56,7 @@
-
{#if voiceInput} From 8979987eeda60156493ed9c6f41e68ababc1ccca Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:14:00 +0200 Subject: [PATCH 020/101] fix: drop extra='allow' on FolderForm and FolderUpdateForm (#23648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: drop extra='allow' on FolderForm and FolderUpdateForm These request models were configured to accept arbitrary extra fields, which were then merged into the folder row via form_data.model_dump(). In insert_new_folder the server-assigned user_id is placed before the form spread, so a client-supplied user_id in the request body would override it and the folder would be persisted against another account. Strictly typed inputs are the correct shape for these endpoints — the client has no legitimate reason to send fields beyond the declared ones, and dropping extra='allow' closes the mass-assignment sink at the validation layer instead of relying on every callsite to merge fields in the right order. * fix: reject unknown fields on FolderForm and FolderUpdateForm Address review feedback: dropping extra='allow' fell back to Pydantic v2's default extra='ignore', which only silently drops unknown fields instead of rejecting them. The intent for these request models is a strict input contract — fail fast when a client sends anything the server does not expect — so explicitly set extra='forbid'. This also makes the hardening visible in the form definition rather than implicit in the default. --- backend/open_webui/models/folders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From 715cf9797af6489faeabe73acf2c94cb4bc5d3bf Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 16:25:44 -0500 Subject: [PATCH 021/101] refac --- backend/open_webui/env.py | 5 ++++ backend/open_webui/routers/audio.py | 37 ++++++++++++++++------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 5e1e150d04..fbabd32361 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -517,6 +517,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/routers/audio.py b/backend/open_webui/routers/audio.py index 69744e7219..499c876261 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -54,6 +54,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, ) @@ -1098,24 +1099,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: From 2943955c529138c0e530fd07b6333a0052e3684e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 17:54:08 -0500 Subject: [PATCH 022/101] refac --- src/lib/components/AddToolServerModal.svelte | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) 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; From c767bcaa739f76b1a4337dfd9d6be47adb504825 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 18:20:46 -0500 Subject: [PATCH 023/101] refac --- backend/open_webui/main.py | 28 ++++++++++++++++----------- backend/open_webui/routers/configs.py | 5 ++--- backend/open_webui/utils/oauth.py | 21 +++++++++++++++++++- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 90d101d23b..636b662436 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -557,6 +557,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, @@ -2304,10 +2305,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), @@ -2368,18 +2367,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( diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index bdcf247230..7c54c09039 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -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 @@ -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/utils/oauth.py b/backend/open_webui/utils/oauth.py index 767e4db6a6..945f31f35d 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -548,6 +548,25 @@ 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() @@ -624,7 +643,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}') From c8ef7b028931263e8773cb60a7111d80d9572d26 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 18:51:20 -0500 Subject: [PATCH 024/101] refac --- src/lib/components/chat/Chat.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index c0386447c3..1421b4618c 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2030,6 +2030,7 @@ childrenIds: [], role: 'assistant', content: '', + done: false, model: model.id, modelName: model.name ?? model.id, modelIdx: modelIdx ? modelIdx : _modelIdx, From 33a4d1b4122dbbc013d85d9501bb42e4e627612f Mon Sep 17 00:00:00 2001 From: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:15:22 +0300 Subject: [PATCH 025/101] fix: image url to base64 conversion (#23685) --- backend/open_webui/utils/middleware.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 90e0861d82..7613bd33d0 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2054,13 +2054,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) From cf4218e688def6f11d195aeda6665ae5b5376b67 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 21:29:03 -0500 Subject: [PATCH 026/101] refac --- backend/open_webui/main.py | 278 ++++++++++++++---- backend/open_webui/models/chats.py | 3 +- backend/open_webui/routers/chats.py | 3 +- backend/open_webui/utils/automations.py | 11 +- backend/open_webui/utils/middleware.py | 112 ++++++- src/lib/components/chat/Chat.svelte | 232 +++++++-------- .../components/layout/Sidebar/ChatItem.svelte | 13 +- 7 files changed, 462 insertions(+), 190 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 636b662436..32d68caa6a 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -116,7 +116,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 @@ -1693,13 +1693,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), @@ -1722,36 +1739,160 @@ 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 @@ -1783,19 +1924,6 @@ async def chat_completion( except Exception: detail = f'Provider returned HTTP {response.status_code}' raise Exception(detail) - if metadata.get('chat_id') and metadata.get('message_id'): - 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, - }, - ) - except Exception: - pass ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events) @@ -1824,7 +1952,7 @@ 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)}, }, ) @@ -1875,19 +2003,55 @@ 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 target_model_id, assistant_message_id in 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) + + task_id, _ = await create_task( + request.app.state.redis, + process_chat(request, model_form_data, user, per_model_metadata, resolved_model), + 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: + # Legacy/direct: single model, synchronous + metadata['message_id'] = list(message_ids.values())[0] return await process_chat(request, form_data, user, metadata, model) @@ -1962,6 +2126,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', {}) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index b13edbf0bf..3bcfdce03f 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, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 979b9388cf..1980d22362 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 @@ -557,7 +558,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) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index a32b04bbfb..3866eb865a 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -307,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={ @@ -378,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/middleware.py b/backend/open_webui/utils/middleware.py index 7613bd33d0..878d5b3f29 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2153,10 +2153,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 @@ -3061,6 +3061,110 @@ 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. + """ + 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 chat_id.startswith('local:') or not message_id: + return + + try: + 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'), + **(({'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 + if outlet_result and outlet_result.get('messages'): + for msg in outlet_result['messages']: + msg_id = msg.get('id') + if msg_id and msg_id in messages_map: + original = messages_map[msg_id] + if original.get('content') != msg.get('content'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + msg_id, + { + 'content': msg['content'], + 'originalContent': original.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'] @@ -3182,6 +3286,7 @@ async def non_streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + await outlet_filter_handler(ctx) response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: @@ -4693,6 +4798,7 @@ async def streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + await outlet_filter_handler(ctx) except asyncio.CancelledError: log.warning('Task was cancelled!') try: diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 1421b4618c..9618619f78 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -81,7 +81,6 @@ import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval'; import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users'; import { - chatCompleted, generateQueries, chatAction, generateMoACompletion, @@ -491,6 +490,22 @@ if (autoScroll) { scrollToBottom('smooth'); } + } else if (type === 'chat:outlet') { + // Outlet filter ran on backend — sync in-memory state + const outletMessages = data.messages ?? []; + for (const msg of outletMessages) { + if (msg?.id && history.messages[msg.id]) { + const existing = history.messages[msg.id]; + if (existing.content !== msg.content) { + history.messages[msg.id] = { + ...existing, + originalContent: existing.content, + ...msg + }; + } + } + } + history = history; } else if (type === 'chat:message:favorite') { // Update message favorite status message.favorite = data.favorite; @@ -1361,6 +1376,17 @@ taskIds = taskRes.task_ids; } + // If no active tasks and current message is incomplete, generation was interrupted + const currentMessage = history.currentId ? history.messages[history.currentId] : null; + if ( + currentMessage && + currentMessage.role === 'assistant' && + !currentMessage.done && + (!taskIds || taskIds.length === 0) + ) { + currentMessage.done = true; + } + await tick(); return true; @@ -1416,71 +1442,12 @@ }; const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => { - if (!responseMessageId) { - console.error('chatCompleted: missing message id', { - chatId: _chatId, - modelId, - messageCount: messages?.length ?? 0 - }); - return; + // Backend handles outlet filters and persistence inline. + // Just refresh the sidebar chat list. + if ($chatId == _chatId && !$temporaryChatEnabled) { + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); } - - const res = await chatCompleted(localStorage.token, { - model: modelId, - messages: messages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - info: m.info ? m.info : undefined, - timestamp: m.timestamp, - ...(m.usage ? { usage: m.usage } : {}), - ...(m.sources ? { sources: m.sources } : {}) - })), - filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, - model_item: $models.find((m) => m.id === modelId), - chat_id: _chatId, - session_id: $socket?.id, - id: responseMessageId - }).catch((error) => { - toast.error(`${error}`); - messages.at(-1).error = { content: error }; - - return null; - }); - - if (res !== null && res.messages) { - // Update chat history with the new messages - for (const message of res.messages) { - if (message?.id) { - // Add null check for message and message.id - history.messages[message.id] = { - ...history.messages[message.id], - ...(history.messages[message.id].content !== message.content - ? { originalContent: history.messages[message.id].content } - : {}), - ...message - }; - } - } - } - - await tick(); - - if ($chatId == _chatId) { - if (!$temporaryChatEnabled) { - chat = await updateChatById(localStorage.token, _chatId, { - models: selectedModels, - messages: messages, - history: history, - params: params, - files: chatFiles - }); - - currentChatPage.set(1); - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - } - } - taskIds = null; }; @@ -1894,7 +1861,7 @@ saveSessionSelectedModels(); - await sendMessage(history, userMessageId, { newChat: true }); + await sendMessage(history, userMessageId); }; const submitHandler = async (userPrompt, { _raw = false } = {}) => { @@ -1994,13 +1961,11 @@ { messages = null, modelId = null, - modelIdx = null, - newChat = false + modelIdx = null }: { messages?: any[] | null; modelId?: string | null; modelIdx?: number | null; - newChat?: boolean; } = {} ) => { if (autoScroll) { @@ -2019,6 +1984,8 @@ : selectedModels; // Create response messages for each selected model + // Build message_ids map: {model_id: assistant_message_id} + const messageIdsMap: Record = {}; for (const [_modelIdx, modelId] of selectedModelIds.entries()) { const model = $models.filter((m) => m.id === modelId).at(0); @@ -2043,7 +2010,6 @@ // Append messageId to childrenIds of parent message if (parentId !== null && history.messages[parentId]) { - // Add null check before accessing childrenIds history.messages[parentId].childrenIds = [ ...history.messages[parentId].childrenIds, responseMessageId @@ -2051,68 +2017,71 @@ } responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId; + messageIdsMap[modelId] = responseMessageId; } } history = history; - // Create new chat if newChat is true and first user message - if (newChat && _history.messages[_history.currentId].parentId === null) { - _chatId = await initChatHandler(_history); + // New chat — backend generates the chat_id on first request + if (!_chatId) { + if ($temporaryChatEnabled) { + _chatId = `local:${$socket?.id}`; + await chatId.set(_chatId); + } + await tick(); } await tick(); + // Re-clone history so sendMessageSocket gets the response messages we just added _history = structuredClone(history); - // Save chat after all messages have been created - await saveChatHandler(_chatId, _history); - await Promise.all( - selectedModelIds.map(async (modelId, _modelIdx) => { - console.log('modelId', modelId); - const model = $models.filter((m) => m.id === modelId).at(0); + // Vision capability check + for (const mid of selectedModelIds) { + const model = $models.filter((m) => m.id === mid).at(0); + if (model) { + const hasImages = createMessagesList(_history, parentId).some((message) => + message.files?.some( + (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + ) + ); - if (model) { - // If there are image files, check if model is vision capable - // Skip this check if image generation is enabled, as images may be for editing or are generated outputs in the history - const hasImages = createMessagesList(_history, parentId).some((message) => - message.files?.some( - (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') - ) + if ( + hasImages && + !(model.info?.meta?.capabilities?.vision ?? true) && + !imageGenerationEnabled + ) { + toast.error( + $i18n.t('Model {{modelName}} is not vision capable', { + modelName: model.name ?? model.id + }) ); - - if ( - hasImages && - !(model.info?.meta?.capabilities?.vision ?? true) && - !imageGenerationEnabled - ) { - toast.error( - $i18n.t('Model {{modelName}} is not vision capable', { - modelName: model.name ?? model.id - }) - ); - } - - let responseMessageId = - responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`]; - const chatEventEmitter = await getChatEventEmitter(model.id, _chatId); - - scrollToBottom(); - await sendMessageSocket( - model, - messages && messages.length > 0 - ? messages - : createMessagesList(_history, responseMessageId), - _history, - responseMessageId, - _chatId - ); - - if (chatEventEmitter) clearInterval(chatEventEmitter); - } else { - toast.error($i18n.t(`Model {{modelId}} not found`, { modelId })); } - }) - ); + } + } + + // Single request — backend fans out to all models + const primaryModelId = selectedModelIds[0]; + const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0); + const primaryResponseMessageId = messageIdsMap[primaryModelId]; + + if (primaryModel && primaryResponseMessageId) { + const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId); + + scrollToBottom(); + await sendMessageSocket( + primaryModel, + messages && messages.length > 0 + ? messages + : createMessagesList(_history, primaryResponseMessageId), + _history, + primaryResponseMessageId, + _chatId, + selectedModelIds.length > 1 ? messageIdsMap : undefined + ); + + if (chatEventEmitter) clearInterval(chatEventEmitter); + } }; const getFeatures = () => { @@ -2167,7 +2136,7 @@ .map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`))); }; - const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => { + const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId, messageIdsMap?: Record) => { const responseMessage = _history.messages[responseMessageId]; const userMessage = _history.messages[responseMessage.parentId]; @@ -2357,12 +2326,13 @@ model_item: $models.find((m) => m.id === model.id), session_id: $socket?.id, - chat_id: $chatId, + chat_id: _chatId || undefined, folder_id: $selectedFolder?.id ?? undefined, id: responseMessageId, - parent_id: userMessage?.id ?? null, - parent_message: userMessage, + ...(messageIdsMap ? { message_ids: messageIdsMap } : {}), + parent_id: userMessage?.parentId ?? null, + user_message: userMessage, background_tasks: { ...(!$temporaryChatEnabled && @@ -2419,10 +2389,22 @@ if (res.error) { await handleOpenAIError(res.error, responseMessage); } else { + // Backend returns task_ids (multi-model) or task_id (single model) + const newTaskIds = res.task_ids ?? (res.task_id ? [res.task_id] : []); if (taskIds) { - taskIds.push(res.task_id); + taskIds.push(...newTaskIds); } else { - taskIds = [res.task_id]; + taskIds = newTaskIds; + } + + // Backend returns chat_id for new chats — set store + URL + if (res.chat_id && $chatId !== res.chat_id) { + await chatId.set(res.chat_id); + if (!$temporaryChatEnabled) { + window.history.replaceState(history.state, '', `/c/${res.chat_id}`); + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + } } } } diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 465d0d1e08..55a3200cad 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -88,10 +88,21 @@ let mouseOver = false; + // Local state: tracks the last updatedAt seen while the user was viewing + // this chat. Survives prop refreshes from sidebar data re-fetches that + // would overwrite the `lastReadAt` prop with a stale server value. + let viewedAt: number | null = null; + + $: if (id === $chatId) { + viewedAt = updatedAt ?? Date.now() / 1000; + } + + $: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null; + $: unread = id !== $chatId && !$activeChatIds.has(id) && - (lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt)); + (effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt)); const loadChat = async () => { if (!chat) { From 84ec43105cdef17f7e57673487ed38a855a89a0e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 21:33:43 -0500 Subject: [PATCH 027/101] refac --- src/lib/components/chat/Chat.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 9618619f78..786b7c60b5 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -180,6 +180,12 @@ } const navigateHandler = async () => { + // Mark the outgoing chat as read before loading the new one. + // $chatId still holds the previous chat here — loadChat() updates it. + if ($chatId && $chatId !== chatIdProp && !$temporaryChatEnabled) { + updateLastReadAt($chatId); + } + loading = true; prompt = ''; From 39ea7bf63d2f89c7773503cbbf19720d6957cd71 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 21:45:17 -0500 Subject: [PATCH 028/101] chore: dep bump --- backend/requirements-min.txt | 2 +- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index b48006db20..f70ebe0ba3 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -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 2816e8195b..dc8e9c9c08 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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/pyproject.toml b/pyproject.toml index c7f14e2c39..f42126dbce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 9a8c4da67db9cebc1385b5dd5596faa4f43203fa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 21:46:32 -0500 Subject: [PATCH 029/101] chore: deps bump --- backend/requirements-min.txt | 6 +++--- backend/requirements.txt | 6 +++--- pyproject.toml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index f70ebe0ba3..3703bda2bd 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -13,10 +13,10 @@ 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 diff --git a/backend/requirements.txt b/backend/requirements.txt index dc8e9c9c08..dee18b8ad9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,10 +10,10 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index f42126dbce..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", From 45e49d33e51f7720c00b564215484aff9b48b20c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 21:52:19 -0500 Subject: [PATCH 030/101] refac --- backend/open_webui/utils/middleware.py | 2 +- .../chat/Messages/Markdown/MarkdownTokens.svelte | 2 ++ src/lib/components/common/ToolCallDisplay.svelte | 4 +++- src/lib/utils/index.ts | 13 ++++++++++++- src/lib/utils/marked/extension.ts | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 878d5b3f29..e96faf3c1e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -444,7 +444,7 @@ def serialize_output(output: list) -> str: files = result_item.get('files') embeds = result_item.get('embeds', '') - content += f'
\nTool Executed\n
\n' + content += f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
\n' else: content += f'
\nExecuting...\n
\n' diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index 961b731924..da4deaaa12 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -380,6 +380,7 @@ 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/utils/index.ts b/src/lib/utils/index.ts index 68ac38e560..ecaeefab04 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -923,8 +923,19 @@ export const processDetails = (content) => { attributes[attributeMatch[1]] = attributeMatch[2]; } + // New format: result in body content; Old format: result in attribute + let resultText = ''; if (attributes.result) { - content = content.replace(match, unescapeHtml(attributes.result)); + resultText = unescapeHtml(attributes.result); + } else { + // Extract body content (strip ...) + const bodyMatch = match.match(/[\s\S]*?<\/summary>\s*([\s\S]*?)\s*<\/details>/i); + if (bodyMatch && bodyMatch[1].trim()) { + resultText = unescapeHtml(bodyMatch[1].trim()); + } + } + if (resultText) { + content = content.replace(match, resultText); } } } diff --git a/src/lib/utils/marked/extension.ts b/src/lib/utils/marked/extension.ts index eec17adbd0..13f9f7cdc3 100644 --- a/src/lib/utils/marked/extension.ts +++ b/src/lib/utils/marked/extension.ts @@ -60,7 +60,7 @@ function detailsTokenizer(src: string) { } function detailsStart(src: string) { - return src.match(/^
/) ? 0 : -1; + return src.match(/^]/) ? 0 : -1; } function detailsRenderer(token: any) { From a209f7f6e02ec42059e9ae5425a636065d50cee2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 23:23:49 -0500 Subject: [PATCH 031/101] refac --- src/lib/components/chat/Chat.svelte | 8 +++++--- src/lib/components/chat/MessageInput.svelte | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 786b7c60b5..d08bf6c82f 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1861,9 +1861,11 @@ history.currentId = userMessageId; - // focus on chat input - const chatInput = document.getElementById('chat-input'); - chatInput?.focus(); + // focus on chat input (skip during voice call to avoid triggering mobile keyboard) + if (!$showCallOverlay) { + const chatInput = document.getElementById('chat-input'); + chatInput?.focus(); + } saveSessionSelectedModels(); diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index d5361d13a6..ef8d2d4cb2 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -343,7 +343,9 @@ } chatInputElement?.setText(text); - chatInputElement?.focus(); + if (!$showCallOverlay) { + chatInputElement?.focus(); + } if (text !== '') { text = await inputVariableHandler(text); From 18fe17127a7175579506e7456d3e5aba201371e6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 23:33:58 -0500 Subject: [PATCH 032/101] refac --- src/lib/components/chat/Chat.svelte | 89 +++++++++++++++-------------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index d08bf6c82f..bd241bcb35 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2195,53 +2195,54 @@ $settings?.params?.stream_response ?? params?.stream_response ?? true; - + // Always include system prompt — backend extracts it and prepends to DB messages. + // Only temp chats need conversation messages (persisted chats load from DB). let messages = [ params?.system || $settings.system - ? { - role: 'system', - content: `${params?.system ?? $settings?.system ?? ''}` - } - : undefined, - ..._messages.map((message) => ({ - ...message, - content: processDetails(message.content), - // Include output for temp chats (backend will use it and strip before LLM) - ...(message.output ? { output: message.output } : {}) - })) - ].filter((message) => message); + ? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` } + : undefined + ].filter(Boolean); + if ($temporaryChatEnabled) { + messages = [ + ...messages, + ..._messages.map((message) => ({ + ...message, + content: processDetails(message.content), + ...(message.output ? { output: message.output } : {}) + })) + ].filter((message) => message); - messages = messages - .map((message, idx, arr) => { - const imageFiles = (message?.files ?? []).filter( - (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') - ); + messages = messages + .map((message, idx, arr) => { + const imageFiles = (message?.files ?? []).filter( + (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + ); - return { - role: message.role, - // Preserve output items so backend can reconstruct tool_calls/tool-role messages (temp chats) - ...(message.output ? { output: message.output } : {}), - ...(message.role === 'user' && imageFiles.length > 0 - ? { - content: [ - { - type: 'text', - text: message?.merged?.content ?? message.content - }, - ...imageFiles.map((file) => ({ - type: 'image_url', - image_url: { - url: file.url - } - })) - ] - } - : { - content: message?.merged?.content ?? message.content - }) - }; - }) - .filter((message) => message?.role === 'user' || message?.content?.trim()); + return { + role: message.role, + ...(message.output ? { output: message.output } : {}), + ...(message.role === 'user' && imageFiles.length > 0 + ? { + content: [ + { + type: 'text', + text: message?.merged?.content ?? message.content + }, + ...imageFiles.map((file) => ({ + type: 'image_url', + image_url: { + url: file.url + } + })) + ] + } + : { + content: message?.merged?.content ?? message.content + }) + }; + }) + .filter((message) => message?.role === 'user' || message?.content?.trim()); + } const toolIds = []; const toolServerIds = []; @@ -2303,7 +2304,7 @@ { stream: stream, model: model.id, - messages: messages, + ...(messages.length > 0 ? { messages } : {}), params: { ...$settings?.params, ...params, From f685edd1616acd52b70f3c4951dc0acb4447fd9e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 23:40:09 -0500 Subject: [PATCH 033/101] refac --- backend/requirements-min.txt | 2 +- backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 3703bda2bd..b7dfd69ffd 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -21,7 +21,7 @@ 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 diff --git a/backend/requirements.txt b/backend/requirements.txt index dee18b8ad9..9aaa3aad5d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,7 @@ 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 From cced77b584d6ea46c58fecddb2b3dd5e955c8417 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 00:07:50 -0500 Subject: [PATCH 034/101] refac --- backend/open_webui/config.py | 8 +++++++- backend/open_webui/main.py | 3 +++ src/lib/components/chat/Settings/Account.svelte | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 4a583fdcd0..65f29a4ad7 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1225,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( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 32d68caa6a..71372d0495 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -380,6 +380,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, @@ -856,6 +857,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 @@ -2261,6 +2263,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, 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}
From 37658fd541d18068256c7c0205433a643787b359 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 01:17:39 -0500 Subject: [PATCH 035/101] refac --- backend/open_webui/utils/misc.py | 10 ++++++++-- backend/open_webui/utils/session_pool.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 7b52f0afdf..345165db28 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -906,10 +906,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/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): From ee28032fb9bd830faca716a089e40429f3776eb8 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:47:48 +0200 Subject: [PATCH 036/101] fix(middleware): replace BaseHTTPMiddleware HTTP middlewares with pure ASGI implementations (#23709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(middleware): replace BaseHTTPMiddleware HTTP middlewares with pure ASGI implementations Starlette's BaseHTTPMiddleware (and the @app.middleware('http') decorator that uses it) wraps the downstream app in an anyio task group whose cancel scope tears down the inner task on every exit — client disconnect, response complete, or any outer middleware bailing. That CancelledError gets injected into whatever the inner task was awaiting, so DB queries, embedding calls, and other long awaits get killed mid-flight. Under aiosqlite the cleanup path then logs a multi-page `terminate_force_close() not implemented` traceback at ERROR for every cancelled DB call. Open WebUI had four such middlewares stacked (`commit_session_after_request`, `check_url`, `inspect_websocket`, `RedirectMiddleware`) so a single cancellation would compound through all four. Move the four middlewares to a new `open_webui.utils.asgi_middleware` module as plain ASGI classes (`__call__(scope, receive, send)`): * `CommitSessionMiddleware` — was `commit_session_after_request`; now also rolls back if commit fails before releasing the connection. * `AuthTokenMiddleware` — was `check_url`; sets request.state token + enable_api_keys + stamps X-Process-Time via a wrapped send. * `WebsocketUpgradeGuardMiddleware` — was `inspect_websocket`; rejects /ws/socket.io HTTP requests that claim transport=websocket without a proper Upgrade/Connection header. * `RedirectMiddleware` — was the BaseHTTPMiddleware subclass; same /watch + share-target rewrites. Pure ASGI does not introduce a cancel scope around the downstream app, so client disconnects propagate via `receive()` (the way ASGI was designed) instead of being injected as CancelledError. Middleware ordering is preserved. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(middleware): CommitSessionMiddleware — rollback on downstream error, never commit failed requests The first cut put commit() in a finally block, which meant that even when a downstream handler raised, the middleware would still commit whatever partial sync writes that handler had made before the failure. That regressed the previous BaseHTTPMiddleware semantics where commit only ran on the success path. Restructure the failure handling: * Downstream raised → rollback any pending sync work, release the connection, re-raise so the outer error middleware turns it into an error response. We never commit a request that did not complete. * Downstream returned → commit. On commit failure, log loudly, rollback, and re-raise. ScopedSession.remove() always runs in finally so the connection cannot leak. Document the inherent pure-ASGI limitation explicitly: by the time `await self.app(...)` returns the response messages have already been emitted, so a commit failure can no longer change what the client sees on the wire. Buffering the response to gate it on commit success would break streaming responses (chat completions, SSE) which are core to Open WebUI; the trade-off is intentional. Routes that need commit-before-send must manage the sync session explicitly. Also drop unused `typing` imports flagged by review. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 --------- Co-authored-by: Claude --- backend/open_webui/main.py | 113 ++------ backend/open_webui/utils/asgi_middleware.py | 273 ++++++++++++++++++++ 2 files changed, 290 insertions(+), 96 deletions(-) create mode 100644 backend/open_webui/utils/asgi_middleware.py diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 71372d0495..500d874e0c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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,6 +57,12 @@ 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 @@ -1359,103 +1364,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 (Anthropic-compatible clients use this - # for ALL requests, including GET /v1/models, not just POST /v1/messages). - if request.state.token is None and request.headers.get('x-api-key'): - 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( diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py new file mode 100644 index 0000000000..4d29a79e66 --- /dev/null +++ b/backend/open_webui/utils/asgi_middleware.py @@ -0,0 +1,273 @@ +""" +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 From 804f9f31534a2e2f8e52c82e7de6546a1d940d0b Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:50:18 +0200 Subject: [PATCH 037/101] fix(retrieval): offload sync VECTOR_DB_CLIENT calls in async paths via AsyncVectorDBClient (#23706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(retrieval): offload sync VECTOR_DB_CLIENT calls in async paths via AsyncVectorDBClient The vector DB backends (Chroma, pgvector, Qdrant, Milvus, Pinecone, Weaviate, …) are uniformly synchronous and their methods perform blocking network or disk I/O. Multiple async route handlers and helpers were calling them directly on the event loop — file processing, memories, knowledge bases, hybrid search bookkeeping — so a single upsert/delete/search would freeze every other in-flight request for the duration of the call. Introduce `AsyncVectorDBClient`, a thin async facade that wraps the existing sync client and dispatches each method through `asyncio.to_thread`. It mirrors `VectorDBBase` exactly and forwards *args/**kwargs so backend-specific extra parameters keep working. Update every async-context call site (routers/retrieval, routers/files, routers/memories, routers/knowledge, retrieval/utils, tools/builtin) to await `ASYNC_VECTOR_DB_CLIENT` instead of calling the sync client directly. Two helpers that were sync-only also acquire async siblings or are awaited via `asyncio.to_thread` at their async call site (`remove_knowledge_base_metadata_embedding`, `get_all_items_from_collections`, `query_doc`). The original sync `VECTOR_DB_CLIENT` is unchanged, so callers that already run inside `run_in_threadpool` (e.g. `save_docs_to_vector_db` and the sync `query_doc`/`get_doc` helpers) are unaffected. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): restore explicit AsyncVectorDBClient signatures matching VectorDBBase Per PR review: the original *args/**kwargs forwarding lost type safety and IDE/static-analysis support. Restore explicit signatures that mirror VectorDBBase exactly, so: * Bad kwargs fail at the facade boundary instead of inside the worker thread (where the resulting TypeError tends to be swallowed by surrounding `try/except`). * IDE autocomplete and static analysis work as expected. * The stated intent ("mirror VectorDBBase exactly") now holds at the API contract level, not just behaviourally. While doing this, surface a pre-existing bug in `delete_entries_from_collection` that the stricter typing flagged: the call passed `metadata={'hash': hash}` which is not a parameter on `VectorDBBase.delete` nor any backend. The TypeError raised inside the sync delete was silently swallowed by `except Exception` so the endpoint always reported `{'status': False}` for every request instead of actually deleting matching vectors. Replace with `filter=...` to do what the endpoint name promises. The thorough review's other note (no concurrency/backpressure on the shared default threadpool) is intentionally not addressed here: asyncio.to_thread on the shared executor is the right primitive for this use case; per-domain bounded executors would add lifecycle complexity disproportionate to the problem and the loop is no longer blocked, which was the actual bug. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): parallelize hybrid-search collection prefetch; document async facade contracts Address PR review findings: 1. Hybrid-search prefetch was sequential `query_collection_with_hybrid_search` previously awaited `ASYNC_VECTOR_DB_CLIENT.get(name)` once per collection in a for loop. Each call already off-loaded to a worker thread, but awaiting them serially meant total prefetch latency scaled linearly with the number of collections. Run them concurrently with `asyncio.gather` so multi-collection queries actually benefit from the threadpool. Per-collection exception handling is preserved by wrapping each fetch in a small helper that logs and returns `(name, None)` on failure, so a single bad collection cannot poison the whole gather. 2. Document the thread-safety expectation explicitly The facade now formally states what was always implicit: the sync `VECTOR_DB_CLIENT` is shared across worker threads, so the underlying backend driver must be thread-safe. This is not a new exposure — `save_docs_to_vector_db` already called the sync client from `run_in_threadpool`. Adding a global lock here would defeat the responsiveness the facade exists to provide; backends that cannot tolerate concurrent access should grow their own internal serialization. 3. Document the API-surface choice and `.sync` escape hatch The strict `VectorDBBase` mirror was a deliberate choice (the previous `*args/**kwargs` revision let a `metadata=` typo silently break an endpoint). Document it, and call out the `.sync` escape hatch with an example for callers that genuinely need a backend-specific parameter not on `VectorDBBase`. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): guard /delete against null file.hash and let HTTPException reach the client Address PR review finding on the `metadata=` → `filter=` change in `delete_entries_from_collection`. The new `filter={'hash': hash}` query was correct for files that have a hash, but did not handle `file.hash is None` (unprocessed, failed, or legacy records). The match semantics of a null filter value are backend-dependent — some ignore the key entirely, some treat it as "metadata field absent" and match every such row — so issuing the query risked deleting unrelated entries. * Reject `hash is None` up front with a 400 explaining the file has no hash to target. * Narrow the surrounding `except Exception` so it no longer swallows `HTTPException`. Without this fix the new 400 (and the pre-existing 404 for missing files) would be silently re-shaped into `{'status': False}` and the caller could not distinguish a bad-request input from a backend error. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 --------- Co-authored-by: Claude --- backend/open_webui/retrieval/utils.py | 35 +++-- .../retrieval/vector/async_client.py | 135 ++++++++++++++++++ backend/open_webui/routers/files.py | 12 +- backend/open_webui/routers/knowledge.py | 28 ++-- backend/open_webui/routers/memories.py | 16 +-- backend/open_webui/routers/retrieval.py | 48 +++++-- backend/open_webui/tools/builtin.py | 8 +- 7 files changed, 232 insertions(+), 50 deletions(-) create mode 100644 backend/open_webui/retrieval/vector/async_client.py diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index f7d2775c52..b4bd615ccf 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,26 @@ 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...') @@ -1140,7 +1151,11 @@ async def get_sources_from_items( 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..bb49f003a7 --- /dev/null +++ b/backend/open_webui/retrieval/vector/async_client.py @@ -0,0 +1,135 @@ +""" +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/routers/files.py b/backend/open_webui/routers/files.py index 66d7539278..055e09c6bf 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 @@ -407,7 +407,7 @@ async def delete_all_files(user=Depends(get_admin_user), db: AsyncSession = Depe if result: try: Storage.delete_all_files() - VECTOR_DB_CLIENT.reset() + await ASYNC_VECTOR_DB_CLIENT.reset() except Exception as e: log.exception(e) log.error('Error deleting files') @@ -577,7 +577,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, @@ -789,9 +789,9 @@ 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}') @@ -799,7 +799,7 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncS if result: try: Storage.delete_file(file.path) - VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') + 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/knowledge.py b/backend/open_webui/routers/knowledge.py index c6f8ce5ecd..77b72cacf0 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 @@ -732,7 +732,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 +814,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: @@ -830,8 +830,8 @@ async def remove_file_from_knowledge_by_id( 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) @@ -915,13 +915,13 @@ async def delete_knowledge_by_id( # 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 +960,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 cfd8274812..3a42801d01 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,7 +138,7 @@ 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, @@ -175,7 +175,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 +184,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 +230,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 @@ -273,7 +273,7 @@ async def update_memory_by_id( 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 +318,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/retrieval.py b/backend/open_webui/routers/retrieval.py index 417441305e..d58ace149f 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 @@ -1556,7 +1557,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 +1580,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 = [ @@ -2380,7 +2383,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( @@ -2409,7 +2412,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, @@ -2507,7 +2513,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( @@ -2516,13 +2522,39 @@ 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} @@ -2530,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/tools/builtin.py b/backend/open_webui/tools/builtin.py index 61cd5ede4e..8aa27520f3 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}}, From 4866bec0f238198a721c952fe18dd04ba643be33 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 10:55:11 -0500 Subject: [PATCH 038/101] refac --- backend/open_webui/routers/files.py | 15 ++++++++------- backend/open_webui/routers/retrieval.py | 2 +- backend/open_webui/utils/files.py | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 055e09c6bf..7ca1c2e73f 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -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,7 +407,7 @@ 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() + await asyncio.to_thread(Storage.delete_all_files) await ASYNC_VECTOR_DB_CLIENT.reset() except Exception as e: log.exception(e) @@ -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 @@ -798,7 +799,7 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncS result = await Files.delete_file_by_id(id, db=db) if result: try: - Storage.delete_file(file.path) + 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) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index d58ace149f..c0295ff316 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1612,7 +1612,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, diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 9eec22a5c3..eea3a8b486 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -20,6 +20,7 @@ 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 @@ -51,7 +52,7 @@ async def get_image_base64_from_url(url: str) -> Optional[str]: 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(): @@ -172,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 From a3ea7bf0433172380e9af4881eed7a1fbcf8f319 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:55:46 +0200 Subject: [PATCH 039/101] fix(retrieval): offload Loader.load to a worker thread so file uploads stop blocking the event loop (#23705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader.load() dispatches to the underlying langchain document loaders (PyMuPDF, Unstructured, python-docx, Tika, …) which are all synchronous and CPU/IO-bound. process_file() awaited it directly on the event loop, so parsing a non-trivial PDF/DOCX would freeze the entire FastAPI app for the duration of the parse — which is what users experience as "the server hangs whenever I upload a file." Add an `aload()` async wrapper on Loader that runs the sync load on a worker thread via asyncio.to_thread, and update process_file() to await it. The sync API is preserved so existing callers that already run inside run_in_threadpool (e.g. save_docs_to_vector_db) are unaffected. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 Co-authored-by: Claude --- backend/open_webui/retrieval/loaders/main.py | 13 +++++++++++++ backend/open_webui/routers/retrieval.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index c0295ff316..c4f6614adc 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1646,7 +1646,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( From fd93bd3414a1725219e14561bc5640b62f9fd4a1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 11:03:36 -0500 Subject: [PATCH 040/101] refac --- src/lib/components/notes/NoteEditor/Chat.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/notes/NoteEditor/Chat.svelte b/src/lib/components/notes/NoteEditor/Chat.svelte index 2430f602e1..f8e4b0c914 100644 --- a/src/lib/components/notes/NoteEditor/Chat.svelte +++ b/src/lib/components/notes/NoteEditor/Chat.svelte @@ -169,13 +169,20 @@ 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 ]) ); From 26a645f9e64914799bafa343413d95bd295cf6b7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 12:18:24 -0500 Subject: [PATCH 041/101] refac: license wording --- LICENSE | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 From f102060a6d85db4acd3d0bf5c25e976f36cd5533 Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Wed, 15 Apr 2026 01:20:47 +0800 Subject: [PATCH 042/101] fix: fix memory leaking of Drawer (#23724) --- src/lib/components/common/Drawer.svelte | 1 + 1 file changed, 1 insertion(+) 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)) { From a4ed16999eec9a654a37c2bb4c15ba5ecd1fa3b7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 16:08:14 -0500 Subject: [PATCH 043/101] refac --- backend/open_webui/main.py | 18 +++++++++++++----- src/lib/components/chat/Chat.svelte | 7 ++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 500d874e0c..2edf103630 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -583,7 +583,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') @@ -1827,7 +1827,7 @@ 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) @@ -1931,7 +1931,7 @@ async def chat_completion( task_ids = [] chat_id = metadata['chat_id'] - for target_model_id, assistant_message_id in message_ids.items(): + for idx, (target_model_id, assistant_message_id) in enumerate(message_ids.items()): if not assistant_message_id: continue @@ -1951,9 +1951,17 @@ async def chat_completion( # 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), + 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) @@ -1975,7 +1983,7 @@ async def chat_completion( else: # Legacy/direct: single model, synchronous metadata['message_id'] = list(message_ids.values())[0] - return await process_chat(request, form_data, user, metadata, model) + return await process_chat(request, form_data, user, metadata, model, tasks) # Alias for chat_completion (Legacy) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index bd241bcb35..b1bec1e4e8 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2345,11 +2345,8 @@ background_tasks: { ...(!$temporaryChatEnabled && - (messages.length == 1 || - (messages.length == 2 && - messages.at(0)?.role === 'system' && - messages.at(1)?.role === 'user')) && - (selectedModels[0] === model.id || atSelectedModel !== undefined) + !_chatId && + (userMessage?.parentId ?? null) === null ? { title_generation: $settings?.title?.auto ?? true, tags_generation: $settings?.autoTags ?? true From 8bd23b91459914eb7df5b5a66567d3544e0da168 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 16:47:43 -0500 Subject: [PATCH 044/101] refac --- backend/open_webui/routers/ollama.py | 2 ++ backend/open_webui/utils/payload.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index d06ceee6ec..9272db1e6a 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -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): 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) From ecd74f220c7dd671d5705189a3f4493a3868c8bf Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 14 Apr 2026 17:22:54 -0500 Subject: [PATCH 045/101] refac --- .../e1f2a3b4c5d6_add_is_pinned_to_note.py | 23 ++++++ backend/open_webui/models/notes.py | 38 ++++++++- backend/open_webui/routers/notes.py | 80 +++++++++++++++++++ src/lib/apis/notes/index.ts | 63 +++++++++++++++ src/lib/components/layout/Sidebar.svelte | 54 +++++++++++++ src/lib/components/notes/NoteEditor.svelte | 13 ++- src/lib/components/notes/Notes.svelte | 18 ++++- .../components/notes/Notes/NoteMenu.svelte | 22 +++++ src/lib/stores/index.ts | 1 + 9 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py 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/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/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/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 07e249a889..c7253871a4 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -313,3 +313,66 @@ 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/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index e28229989e..dcd1c7cc78 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,7 @@ } 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 { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import ArchivedChatsModal from './ArchivedChatsModal.svelte'; @@ -86,6 +88,7 @@ let pinnedModels = []; let showPinnedModels = false; + let showPinnedNotes = false; let showChannels = false; let showFolders = false; @@ -227,6 +230,13 @@ 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 +1082,50 @@ {/if} + {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0} + + + + {/if} + {#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))} { 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/Notes.svelte b/src/lib/components/notes/Notes.svelte index 4b4a208a83..e166fb308a 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,12 @@ 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} + + diff --git a/src/lib/components/notes/NoteEditor/Chat.svelte b/src/lib/components/notes/NoteEditor/Chat.svelte index f8e4b0c914..80a54adc38 100644 --- a/src/lib/components/notes/NoteEditor/Chat.svelte +++ b/src/lib/components/notes/NoteEditor/Chat.svelte @@ -172,9 +172,7 @@ Based on the user's instruction, update and enhance the existing notes or select // 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 filteredMessages = messages.filter((m) => !(m.role === 'assistant' && m.content === '')); const chatMessages = JSON.parse( JSON.stringify([ diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index e166fb308a..e7762cf258 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -545,7 +545,9 @@ isPinned={note.is_pinned ?? false} onPin={async () => { await toggleNotePinnedStatusById(localStorage.token, note.id); - pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); + pinnedNotes.set( + await getPinnedNoteList(localStorage.token).catch(() => []) + ); init(); }} > @@ -613,7 +615,9 @@ isPinned={note.is_pinned ?? false} onPin={async () => { await toggleNotePinnedStatusById(localStorage.token, note.id); - pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => [])); + pinnedNotes.set( + await getPinnedNoteList(localStorage.token).catch(() => []) + ); init(); }} > diff --git a/src/lib/components/notes/Notes/NoteMenu.svelte b/src/lib/components/notes/Notes/NoteMenu.svelte index 90be6fac6a..8658d7396f 100644 --- a/src/lib/components/notes/Notes/NoteMenu.svelte +++ b/src/lib/components/notes/Notes/NoteMenu.svelte @@ -115,24 +115,24 @@ {/if} {#if onPin} - - {/if} + + {/if} -
-
+
{#if voiceInput}
diff --git a/src/lib/i18n/locales/ar-BH/translation.json b/src/lib/i18n/locales/ar-BH/translation.json index 34647cd1f6..0de2536779 100644 --- a/src/lib/i18n/locales/ar-BH/translation.json +++ b/src/lib/i18n/locales/ar-BH/translation.json @@ -458,6 +458,7 @@ "Create a new note": "", "Create Account": "إنشاء حساب", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1517,6 +1518,7 @@ "Persistent": "", "Personalization": "التخصيص", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ar/translation.json b/src/lib/i18n/locales/ar/translation.json index 9e64b36427..148385da6f 100644 --- a/src/lib/i18n/locales/ar/translation.json +++ b/src/lib/i18n/locales/ar/translation.json @@ -458,6 +458,7 @@ "Create a new note": "", "Create Account": "إنشاء حساب", "Create Admin Account": "إنشاء حساب مسؤول", + "Create and manage scheduled automations": "", "Create Channel": "إنشاء قناة", "Create Folder": "", "Create Image": "", @@ -1517,6 +1518,7 @@ "Persistent": "", "Personalization": "التخصيص", "Pin": "تثبيت", + "Pin to Sidebar": "", "Pinned": "مثبت", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/az-AZ/translation.json b/src/lib/i18n/locales/az-AZ/translation.json index e9c75e8671..08278d31db 100644 --- a/src/lib/i18n/locales/az-AZ/translation.json +++ b/src/lib/i18n/locales/az-AZ/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Yeni qeyd yarat", "Create Account": "Hesab yarat", "Create Admin Account": "Admin hesabı yarat", + "Create and manage scheduled automations": "", "Create Channel": "Kanal yarat", "Create Folder": "Qovluq yarat", "Create Image": "Şəkil yarat", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Fərdiləşdirmə", "Pin": "Bərkit", + "Pin to Sidebar": "", "Pinned": "Bərkidilib", "Pinned Messages": "Bərkidilmiş mesajlar", "Pinned Models": "Bərkidilmiş modellər", diff --git a/src/lib/i18n/locales/bg-BG/translation.json b/src/lib/i18n/locales/bg-BG/translation.json index e425f2063d..8880a92c26 100644 --- a/src/lib/i18n/locales/bg-BG/translation.json +++ b/src/lib/i18n/locales/bg-BG/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Създаване на Акаунт", "Create Admin Account": "Създаване на администраторски акаунт", + "Create and manage scheduled automations": "", "Create Channel": "Създаване на канал", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Персонализация", "Pin": "Закачи", + "Pin to Sidebar": "", "Pinned": "Закачено", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/bn-BD/translation.json b/src/lib/i18n/locales/bn-BD/translation.json index fecb6f5ffe..ee3f228442 100644 --- a/src/lib/i18n/locales/bn-BD/translation.json +++ b/src/lib/i18n/locales/bn-BD/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "একাউন্ট তৈরি করুন", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "ডিজিটাল বাংলা", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/bo-TB/translation.json b/src/lib/i18n/locales/bo-TB/translation.json index 30ca5f031a..57f7f7cbc0 100644 --- a/src/lib/i18n/locales/bo-TB/translation.json +++ b/src/lib/i18n/locales/bo-TB/translation.json @@ -453,6 +453,7 @@ "Create a new note": "", "Create Account": "རྩིས་ཁྲ་གསར་བཟོ།", "Create Admin Account": "དོ་དམ་པའི་རྩིས་ཁྲ་གསར་བཟོ།", + "Create and manage scheduled automations": "", "Create Channel": "བགྲོ་གླེང་གསར་བཟོ།", "Create Folder": "", "Create Image": "", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "སྒེར་སྤྱོད་ཅན།", "Pin": "གདབ་པ།", + "Pin to Sidebar": "", "Pinned": "གདབ་ཟིན།", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/bs-BA/translation.json b/src/lib/i18n/locales/bs-BA/translation.json index e539d89c1c..c0b4bc85af 100644 --- a/src/lib/i18n/locales/bs-BA/translation.json +++ b/src/lib/i18n/locales/bs-BA/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Stvori račun", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Prilagodba", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ca-ES/translation.json b/src/lib/i18n/locales/ca-ES/translation.json index fad81172aa..4db5f81f15 100644 --- a/src/lib/i18n/locales/ca-ES/translation.json +++ b/src/lib/i18n/locales/ca-ES/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Crear una nova nota", "Create Account": "Crear un compte", "Create Admin Account": "Crear un compte d'Administrador", + "Create and manage scheduled automations": "", "Create Channel": "Crear un canal", "Create Folder": "Crear carpeta", "Create Image": "Crear imatge", @@ -1514,6 +1515,7 @@ "Persistent": "Persistent", "Personalization": "Personalització", "Pin": "Fixar", + "Pin to Sidebar": "", "Pinned": "Fixat", "Pinned Messages": "Missatges fixats", "Pinned Models": "Models fixats", diff --git a/src/lib/i18n/locales/ceb-PH/translation.json b/src/lib/i18n/locales/ceb-PH/translation.json index 138fa01b1e..0690da49df 100644 --- a/src/lib/i18n/locales/ceb-PH/translation.json +++ b/src/lib/i18n/locales/ceb-PH/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Paghimo og account", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/cs-CZ/translation.json b/src/lib/i18n/locales/cs-CZ/translation.json index 6bb5fca5b3..1f0dc64af5 100644 --- a/src/lib/i18n/locales/cs-CZ/translation.json +++ b/src/lib/i18n/locales/cs-CZ/translation.json @@ -456,6 +456,7 @@ "Create a new note": "", "Create Account": "Vytvořit účet", "Create Admin Account": "Vytvořit účet administrátora", + "Create and manage scheduled automations": "", "Create Channel": "Vytvořit kanál", "Create Folder": "Vytvořit složku", "Create Image": "", @@ -1515,6 +1516,7 @@ "Persistent": "", "Personalization": "Personalizace", "Pin": "Připnout", + "Pin to Sidebar": "", "Pinned": "Připnuto", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/da-DK/translation.json b/src/lib/i18n/locales/da-DK/translation.json index 58c6d6adb1..1277b42e29 100644 --- a/src/lib/i18n/locales/da-DK/translation.json +++ b/src/lib/i18n/locales/da-DK/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Opret ny note", "Create Account": "Opret profil", "Create Admin Account": "Opret administrator profil", + "Create and manage scheduled automations": "", "Create Channel": "Opret kanal", "Create Folder": "Opret mappe", "Create Image": "Opret billede", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalisering", "Pin": "Fastgør", + "Pin to Sidebar": "", "Pinned": "Fastgjort", "Pinned Messages": "Fastgjorte beskeder", "Pinned Models": "", diff --git a/src/lib/i18n/locales/de-DE/translation.json b/src/lib/i18n/locales/de-DE/translation.json index 4fe9f1a2cb..f018a6e7ac 100644 --- a/src/lib/i18n/locales/de-DE/translation.json +++ b/src/lib/i18n/locales/de-DE/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Neue Notiz erstellen", "Create Account": "Konto erstellen", "Create Admin Account": "Admin-Konto erstellen", + "Create and manage scheduled automations": "", "Create Channel": "Kanal erstellen", "Create Folder": "Ordner erstellen", "Create Image": "Bild erstellen", @@ -1513,6 +1514,7 @@ "Persistent": "Persistent", "Personalization": "Personalisierung", "Pin": "Anheften", + "Pin to Sidebar": "", "Pinned": "Angeheftet", "Pinned Messages": "Angeheftete Nachrichten", "Pinned Models": "Angepinnte Modelle", diff --git a/src/lib/i18n/locales/dg-DG/translation.json b/src/lib/i18n/locales/dg-DG/translation.json index c4a87b0687..c70505612d 100644 --- a/src/lib/i18n/locales/dg-DG/translation.json +++ b/src/lib/i18n/locales/dg-DG/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Create Account", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalization", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/el-GR/translation.json b/src/lib/i18n/locales/el-GR/translation.json index fe4da3a8d4..2fa6e3634e 100644 --- a/src/lib/i18n/locales/el-GR/translation.json +++ b/src/lib/i18n/locales/el-GR/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Δημιουργία Λογαριασμού", "Create Admin Account": "Δημιουργία Λογαριασμού Διαχειριστή", + "Create and manage scheduled automations": "", "Create Channel": "Δημιουργία Καναλιού", "Create Folder": "Δημιουργία Φακέλου", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Προσωποποίηση", "Pin": "Καρφίτσωμα", + "Pin to Sidebar": "", "Pinned": "Καρφιτσωμένο", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/en-GB/translation.json b/src/lib/i18n/locales/en-GB/translation.json index e688ca17b1..2c8618483b 100644 --- a/src/lib/i18n/locales/en-GB/translation.json +++ b/src/lib/i18n/locales/en-GB/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalisation", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 7ac7cb3e5b..8a0d95f0aa 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/es-ES/translation.json b/src/lib/i18n/locales/es-ES/translation.json index b1339c0456..a7bc94434d 100644 --- a/src/lib/i18n/locales/es-ES/translation.json +++ b/src/lib/i18n/locales/es-ES/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Crea una nueva nota", "Create Account": "Crear Cuenta", "Create Admin Account": "Crear Cuenta Administrativa", + "Create and manage scheduled automations": "", "Create Channel": "Crear Canal", "Create Folder": "Crear Carpeta", "Create Image": "Crear Imagen", @@ -1514,6 +1515,7 @@ "Persistent": "Persistente", "Personalization": "Personalización", "Pin": "Fijar", + "Pin to Sidebar": "", "Pinned": "Fijado", "Pinned Messages": "Mensajes Fijados", "Pinned Models": "Modelos Fijados", diff --git a/src/lib/i18n/locales/et-EE/translation.json b/src/lib/i18n/locales/et-EE/translation.json index 4f7bc75de7..f4d287509b 100644 --- a/src/lib/i18n/locales/et-EE/translation.json +++ b/src/lib/i18n/locales/et-EE/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Loo uus märge", "Create Account": "Loo konto", "Create Admin Account": "Loo administraatori konto", + "Create and manage scheduled automations": "", "Create Channel": "Loo kanal", "Create Folder": "Loo kaust", "Create Image": "Loo pilt", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Isikupärastamine", "Pin": "Kinnita", + "Pin to Sidebar": "", "Pinned": "Kinnitatud", "Pinned Messages": "Kinnitatud sõnumid", "Pinned Models": "Kinnitatud mudelid", diff --git a/src/lib/i18n/locales/eu-ES/translation.json b/src/lib/i18n/locales/eu-ES/translation.json index ab7ee3117b..a508665faf 100644 --- a/src/lib/i18n/locales/eu-ES/translation.json +++ b/src/lib/i18n/locales/eu-ES/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Sortu Kontua", "Create Admin Account": "Sortu Administratzaile Kontua", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Pertsonalizazioa", "Pin": "Ainguratu", + "Pin to Sidebar": "", "Pinned": "Ainguratuta", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/fa-IR/translation.json b/src/lib/i18n/locales/fa-IR/translation.json index 725f340932..3b35670685 100644 --- a/src/lib/i18n/locales/fa-IR/translation.json +++ b/src/lib/i18n/locales/fa-IR/translation.json @@ -454,6 +454,7 @@ "Create a new note": "ایجاد یک یادداشت جدید", "Create Account": "ساخت حساب کاربری", "Create Admin Account": "ایجاد حساب مدیر", + "Create and manage scheduled automations": "", "Create Channel": "ایجاد کانال", "Create Folder": "ایجاد پوشه", "Create Image": "ایجاد تصویر", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "شخصی سازی", "Pin": "پین کردن", + "Pin to Sidebar": "", "Pinned": "پین شده", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/fi-FI/translation.json b/src/lib/i18n/locales/fi-FI/translation.json index 94a728a77f..5e6e8464e0 100644 --- a/src/lib/i18n/locales/fi-FI/translation.json +++ b/src/lib/i18n/locales/fi-FI/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Luo uusi muistiinpano", "Create Account": "Luo tili", "Create Admin Account": "Luo ylläpitäjätili", + "Create and manage scheduled automations": "", "Create Channel": "Luo kanava", "Create Folder": "Luo kansio", "Create Image": "Luo kuva", @@ -1513,6 +1514,7 @@ "Persistent": "Pysyvä", "Personalization": "Personointi", "Pin": "Kiinnitä", + "Pin to Sidebar": "", "Pinned": "Kiinnitetty", "Pinned Messages": "Kiinnitetyt viestit", "Pinned Models": "Kiinnitetyt mallit", diff --git a/src/lib/i18n/locales/fr-CA/translation.json b/src/lib/i18n/locales/fr-CA/translation.json index de0021689b..997b4d36d3 100644 --- a/src/lib/i18n/locales/fr-CA/translation.json +++ b/src/lib/i18n/locales/fr-CA/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Créer un compte", "Create Admin Account": "Créer un compte administrateur", + "Create and manage scheduled automations": "", "Create Channel": "Créer un canal", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personnalisation", "Pin": "Épingler", + "Pin to Sidebar": "", "Pinned": "Épinglé", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/fr-FR/translation.json b/src/lib/i18n/locales/fr-FR/translation.json index a8a8a8dd52..0723429838 100644 --- a/src/lib/i18n/locales/fr-FR/translation.json +++ b/src/lib/i18n/locales/fr-FR/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Créer une nouvelle note", "Create Account": "Créer un compte", "Create Admin Account": "Créer un compte administrateur", + "Create and manage scheduled automations": "", "Create Channel": "Créer un canal", "Create Folder": "Créer un dossier", "Create Image": "Création d'image", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personnalisation", "Pin": "Épingler", + "Pin to Sidebar": "", "Pinned": "Épinglé", "Pinned Messages": "Messages épinglés", "Pinned Models": "Modèles épinglés", diff --git a/src/lib/i18n/locales/gl-ES/translation.json b/src/lib/i18n/locales/gl-ES/translation.json index d831953ddf..24df9a7c6c 100644 --- a/src/lib/i18n/locales/gl-ES/translation.json +++ b/src/lib/i18n/locales/gl-ES/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Xerar unha conta", "Create Admin Account": "Xerar conta administrativa", + "Create and manage scheduled automations": "", "Create Channel": "Xerar Canal", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalización", "Pin": "Fijar", + "Pin to Sidebar": "", "Pinned": "Fijado", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/he-IL/translation.json b/src/lib/i18n/locales/he-IL/translation.json index 99feaf7600..495157d57a 100644 --- a/src/lib/i18n/locales/he-IL/translation.json +++ b/src/lib/i18n/locales/he-IL/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "צור חשבון", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "תאור", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/hi-IN/translation.json b/src/lib/i18n/locales/hi-IN/translation.json index 71edceb884..cf07ebe128 100644 --- a/src/lib/i18n/locales/hi-IN/translation.json +++ b/src/lib/i18n/locales/hi-IN/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "खाता बनाएं", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "पेरसनलाइज़मेंट", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/hr-HR/translation.json b/src/lib/i18n/locales/hr-HR/translation.json index 33ee5f734a..51ff1d8636 100644 --- a/src/lib/i18n/locales/hr-HR/translation.json +++ b/src/lib/i18n/locales/hr-HR/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Stvori račun", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Prilagodba", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/hu-HU/translation.json b/src/lib/i18n/locales/hu-HU/translation.json index f411786290..df5ccc2150 100644 --- a/src/lib/i18n/locales/hu-HU/translation.json +++ b/src/lib/i18n/locales/hu-HU/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Fiók létrehozása", "Create Admin Account": "Admin fiók létrehozása", + "Create and manage scheduled automations": "", "Create Channel": "Csatorna létrehozása", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Személyre szabás", "Pin": "Rögzítés", + "Pin to Sidebar": "", "Pinned": "Rögzítve", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/id-ID/translation.json b/src/lib/i18n/locales/id-ID/translation.json index 0d36c77484..186ced34e1 100644 --- a/src/lib/i18n/locales/id-ID/translation.json +++ b/src/lib/i18n/locales/id-ID/translation.json @@ -453,6 +453,7 @@ "Create a new note": "", "Create Account": "Buat Akun", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "Personalisasi", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ie-GA/translation.json b/src/lib/i18n/locales/ie-GA/translation.json index ba6fdbc60d..c705f86a56 100644 --- a/src/lib/i18n/locales/ie-GA/translation.json +++ b/src/lib/i18n/locales/ie-GA/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Cruthaigh nóta nua", "Create Account": "Cruthaigh Cuntas", "Create Admin Account": "Cruthaigh Cuntas Riaracháin", + "Create and manage scheduled automations": "", "Create Channel": "Cruthaigh Cainéal", "Create Folder": "Cruthaigh Fillteán", "Create Image": "Cruthaigh Íomhá", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Pearsantú", "Pin": "Bioráin", + "Pin to Sidebar": "", "Pinned": "Pinneáilte", "Pinned Messages": "Teachtaireachtaí Pionáilte", "Pinned Models": "Samhlacha bioráilte", diff --git a/src/lib/i18n/locales/it-IT/translation.json b/src/lib/i18n/locales/it-IT/translation.json index eb3e42aa68..6635624260 100644 --- a/src/lib/i18n/locales/it-IT/translation.json +++ b/src/lib/i18n/locales/it-IT/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Crea account", "Create Admin Account": "Crea account amministratore", + "Create and manage scheduled automations": "", "Create Channel": "Crea canale", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personalizzazione", "Pin": "Appunta", + "Pin to Sidebar": "", "Pinned": "Appuntato", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ja-JP/translation.json b/src/lib/i18n/locales/ja-JP/translation.json index b559404dc9..54027dfee9 100644 --- a/src/lib/i18n/locales/ja-JP/translation.json +++ b/src/lib/i18n/locales/ja-JP/translation.json @@ -453,6 +453,7 @@ "Create a new note": "新しいノートを作成する", "Create Account": "アカウントを作成", "Create Admin Account": "管理者アカウントを作成", + "Create and manage scheduled automations": "", "Create Channel": "チャンネルを作成", "Create Folder": "フォルダを作成", "Create Image": "", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "パーソナライズ", "Pin": "ピン留め", + "Pin to Sidebar": "", "Pinned": "ピン留めされています", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ka-GE/translation.json b/src/lib/i18n/locales/ka-GE/translation.json index 67c99ba548..b220527cde 100644 --- a/src/lib/i18n/locales/ka-GE/translation.json +++ b/src/lib/i18n/locales/ka-GE/translation.json @@ -454,6 +454,7 @@ "Create a new note": "ახალი შენიშვნის შექმნა", "Create Account": "ანგარიშის შექმნა", "Create Admin Account": "ადმინისტრატორის ანგარიშის შექმნა", + "Create and manage scheduled automations": "", "Create Channel": "არხის შექმნა", "Create Folder": "საქაღალდის შექმნა", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "პერსონალიზაცია", "Pin": "მიმაგრება", + "Pin to Sidebar": "", "Pinned": "მიმაგრებულია", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/kab-DZ/translation.json b/src/lib/i18n/locales/kab-DZ/translation.json index b38b37e73c..7b676dd8d9 100644 --- a/src/lib/i18n/locales/kab-DZ/translation.json +++ b/src/lib/i18n/locales/kab-DZ/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Snulfu-d amiḍan", "Create Admin Account": "Snulfu-d amiḍan n unedbal", + "Create and manage scheduled automations": "", "Create Channel": "Snulfu-d abadu", "Create Folder": "Snulfu-d akaram", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Asagen", "Pin": "Senteḍ", + "Pin to Sidebar": "", "Pinned": "Yettwasenteḍ", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ko-KR/translation.json b/src/lib/i18n/locales/ko-KR/translation.json index c4492175ca..947ac14bfe 100644 --- a/src/lib/i18n/locales/ko-KR/translation.json +++ b/src/lib/i18n/locales/ko-KR/translation.json @@ -453,6 +453,7 @@ "Create a new note": "새 노트 생성", "Create Account": "계정 생성", "Create Admin Account": "관리자 계정 생성", + "Create and manage scheduled automations": "", "Create Channel": "채널 생성", "Create Folder": "폴더 생성", "Create Image": "이미지 생성", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "개인화", "Pin": "고정", + "Pin to Sidebar": "", "Pinned": "고정됨", "Pinned Messages": "고정된 메시지", "Pinned Models": "", diff --git a/src/lib/i18n/locales/lt-LT/translation.json b/src/lib/i18n/locales/lt-LT/translation.json index f6e1694dfc..3a34951fcc 100644 --- a/src/lib/i18n/locales/lt-LT/translation.json +++ b/src/lib/i18n/locales/lt-LT/translation.json @@ -456,6 +456,7 @@ "Create a new note": "", "Create Account": "Créer un compte", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1515,6 +1516,7 @@ "Persistent": "", "Personalization": "Personalizacija", "Pin": "Smeigtukas", + "Pin to Sidebar": "", "Pinned": "Įsmeigta", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/lv-LV/translation.json b/src/lib/i18n/locales/lv-LV/translation.json index d3ce9d12f7..d8a507290f 100644 --- a/src/lib/i18n/locales/lv-LV/translation.json +++ b/src/lib/i18n/locales/lv-LV/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Izveidot jaunu piezīmi", "Create Account": "Izveidot kontu", "Create Admin Account": "Izveidot administratora kontu", + "Create and manage scheduled automations": "", "Create Channel": "Izveidot kanālu", "Create Folder": "Izveidot mapi", "Create Image": "Izveidot attēlu", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personalizācija", "Pin": "Piespraust", + "Pin to Sidebar": "", "Pinned": "Piesprausts", "Pinned Messages": "Piespraustie ziņojumi", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ms-MY/translation.json b/src/lib/i18n/locales/ms-MY/translation.json index a819fa33b4..6f919b113c 100644 --- a/src/lib/i18n/locales/ms-MY/translation.json +++ b/src/lib/i18n/locales/ms-MY/translation.json @@ -453,6 +453,7 @@ "Create a new note": "Buat nota baru", "Create Account": "Cipta Akaun", "Create Admin Account": "Buat Akaun Admin", + "Create and manage scheduled automations": "", "Create Channel": "Buat Saluran", "Create Folder": "Buat Folder", "Create Image": "Buat Imej", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "Personalisasi", "Pin": "Pin", + "Pin to Sidebar": "", "Pinned": "Disemat", "Pinned Messages": "Mesej Disematkan", "Pinned Models": "Model Tersapu", diff --git a/src/lib/i18n/locales/nb-NO/translation.json b/src/lib/i18n/locales/nb-NO/translation.json index 2c2d6ba0d2..3269f5b84f 100644 --- a/src/lib/i18n/locales/nb-NO/translation.json +++ b/src/lib/i18n/locales/nb-NO/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Opprett konto", "Create Admin Account": "Opprett administratorkonto", + "Create and manage scheduled automations": "", "Create Channel": "Opprett kanal", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Tilpassing", "Pin": "Fest", + "Pin to Sidebar": "", "Pinned": "Festet", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/nl-NL/translation.json b/src/lib/i18n/locales/nl-NL/translation.json index 9feab00ffe..7d82b00637 100644 --- a/src/lib/i18n/locales/nl-NL/translation.json +++ b/src/lib/i18n/locales/nl-NL/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Maak account", "Create Admin Account": "Maak admin-account", + "Create and manage scheduled automations": "", "Create Channel": "Maak kanaal", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalisatie", "Pin": "Zet vast", + "Pin to Sidebar": "", "Pinned": "Vastgezet", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/pa-IN/translation.json b/src/lib/i18n/locales/pa-IN/translation.json index 952a681610..1570848d77 100644 --- a/src/lib/i18n/locales/pa-IN/translation.json +++ b/src/lib/i18n/locales/pa-IN/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "ਖਾਤਾ ਬਣਾਓ", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "ਪਰਸੋਨਲਿਸ਼ਮ", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/pl-PL/translation.json b/src/lib/i18n/locales/pl-PL/translation.json index 5194bf8be7..d09a123b64 100644 --- a/src/lib/i18n/locales/pl-PL/translation.json +++ b/src/lib/i18n/locales/pl-PL/translation.json @@ -456,6 +456,7 @@ "Create a new note": "Utwórz nową notatkę", "Create Account": "Utwórz konto", "Create Admin Account": "Utwórz konto administratora", + "Create and manage scheduled automations": "", "Create Channel": "Utwórz kanał", "Create Folder": "Utwórz folder", "Create Image": "Utwórz obraz", @@ -1515,6 +1516,7 @@ "Persistent": "", "Personalization": "Personalizacja", "Pin": "Przypnij", + "Pin to Sidebar": "", "Pinned": "Przypięte", "Pinned Messages": "Przypięte wiadomości", "Pinned Models": "", diff --git a/src/lib/i18n/locales/pt-BR/translation.json b/src/lib/i18n/locales/pt-BR/translation.json index c11ee5eac1..3807101013 100644 --- a/src/lib/i18n/locales/pt-BR/translation.json +++ b/src/lib/i18n/locales/pt-BR/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Criar uma nova nota", "Create Account": "Criar Conta", "Create Admin Account": "Criar Conta de Administrador", + "Create and manage scheduled automations": "", "Create Channel": "Criar Canal", "Create Folder": "Criar Pasta", "Create Image": "Criar imagem", @@ -1514,6 +1515,7 @@ "Persistent": "Persistente", "Personalization": "Personalização", "Pin": "Fixar", + "Pin to Sidebar": "", "Pinned": "Fixado", "Pinned Messages": "Mensagens fixadas", "Pinned Models": "Modelos Fixados", diff --git a/src/lib/i18n/locales/pt-PT/translation.json b/src/lib/i18n/locales/pt-PT/translation.json index 7861982f6c..eb5abc3d62 100644 --- a/src/lib/i18n/locales/pt-PT/translation.json +++ b/src/lib/i18n/locales/pt-PT/translation.json @@ -455,6 +455,7 @@ "Create a new note": "Criar uma nova nota", "Create Account": "Criar Conta", "Create Admin Account": "Criar Conta de Administrador", + "Create and manage scheduled automations": "", "Create Channel": "Criar Canal", "Create Folder": "Criar Pasta", "Create Image": "Criar Imagem", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personalização", "Pin": "Fixar", + "Pin to Sidebar": "", "Pinned": "Fixado", "Pinned Messages": "Mensagens Fixadas", "Pinned Models": "Modelos Fixados", diff --git a/src/lib/i18n/locales/ro-RO/translation.json b/src/lib/i18n/locales/ro-RO/translation.json index 49e4e7dba2..954f65c2db 100644 --- a/src/lib/i18n/locales/ro-RO/translation.json +++ b/src/lib/i18n/locales/ro-RO/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Creează Cont", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "Creează canal", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Personalizare", "Pin": "Fixează", + "Pin to Sidebar": "", "Pinned": "Fixat", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ru-RU/translation.json b/src/lib/i18n/locales/ru-RU/translation.json index ebb4e76654..cc7eb89d7f 100644 --- a/src/lib/i18n/locales/ru-RU/translation.json +++ b/src/lib/i18n/locales/ru-RU/translation.json @@ -456,6 +456,7 @@ "Create a new note": "Создать новую заметку", "Create Account": "Создать аккаунт", "Create Admin Account": "Создать аккаунт Администратора", + "Create and manage scheduled automations": "", "Create Channel": "Создать канал", "Create Folder": "Создать папку", "Create Image": "Создать изображение", @@ -1515,6 +1516,7 @@ "Persistent": "Постоянный", "Personalization": "Персонализация", "Pin": "Закрепить", + "Pin to Sidebar": "", "Pinned": "Закреплено", "Pinned Messages": "Закреплённые сообщения", "Pinned Models": "Закреплённые модели", diff --git a/src/lib/i18n/locales/sk-SK/translation.json b/src/lib/i18n/locales/sk-SK/translation.json index a828c7fb3b..4eb09dee30 100644 --- a/src/lib/i18n/locales/sk-SK/translation.json +++ b/src/lib/i18n/locales/sk-SK/translation.json @@ -456,6 +456,7 @@ "Create a new note": "Vytvoriť novú poznámku", "Create Account": "Vytvoriť účet", "Create Admin Account": "Vytvoriť admin účet", + "Create and manage scheduled automations": "", "Create Channel": "Vytvoriť kanál", "Create Folder": "Vytvoriť priečinok", "Create Image": "Vytvoriť obrázok", @@ -1515,6 +1516,7 @@ "Persistent": "", "Personalization": "Personalizácia", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/sr-RS/translation.json b/src/lib/i18n/locales/sr-RS/translation.json index ddfe1d46fc..9b41940d1b 100644 --- a/src/lib/i18n/locales/sr-RS/translation.json +++ b/src/lib/i18n/locales/sr-RS/translation.json @@ -455,6 +455,7 @@ "Create a new note": "", "Create Account": "Направи налог", "Create Admin Account": "Направи админ налог", + "Create and manage scheduled automations": "", "Create Channel": "Направи канал", "Create Folder": "", "Create Image": "", @@ -1514,6 +1515,7 @@ "Persistent": "", "Personalization": "Прилагођавање", "Pin": "Закачи", + "Pin to Sidebar": "", "Pinned": "Закачено", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/sv-SE/translation.json b/src/lib/i18n/locales/sv-SE/translation.json index d8b0674e34..af3c91ac78 100644 --- a/src/lib/i18n/locales/sv-SE/translation.json +++ b/src/lib/i18n/locales/sv-SE/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Skapa en ny anteckning", "Create Account": "Skapa konto", "Create Admin Account": "Skapa administratörskonto", + "Create and manage scheduled automations": "", "Create Channel": "Skapa kanal", "Create Folder": "Skapa mapp", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Personalisering", "Pin": "Fäst", + "Pin to Sidebar": "", "Pinned": "Fäst", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ta-IN/translation.json b/src/lib/i18n/locales/ta-IN/translation.json index d4b413514a..3a5a0d441e 100644 --- a/src/lib/i18n/locales/ta-IN/translation.json +++ b/src/lib/i18n/locales/ta-IN/translation.json @@ -454,6 +454,7 @@ "Create a new note": "புதிய குறிப்பை உருவாக்கவும்", "Create Account": "கணக்கை உருவாக்கவும்", "Create Admin Account": "நிர்வாகி கணக்கை உருவாக்கவும்", + "Create and manage scheduled automations": "", "Create Channel": "சேனலை உருவாக்கவும்", "Create Folder": "கோப்புறையை உருவாக்கவும்", "Create Image": "படத்தை உருவாக்கவும்", @@ -1513,6 +1514,7 @@ "Persistent": "பிடிவாதமான", "Personalization": "தனிப்பயனாக்கம்", "Pin": "பின்", + "Pin to Sidebar": "", "Pinned": "நிலைநிறுத்தப்பட்டவை", "Pinned Messages": "பின் செய்யப்பட்ட செய்திகள்", "Pinned Models": "பின் செய்யப்பட்ட மாதிரிகள்", diff --git a/src/lib/i18n/locales/th-TH/translation.json b/src/lib/i18n/locales/th-TH/translation.json index 3282b044fe..7aa41ba331 100644 --- a/src/lib/i18n/locales/th-TH/translation.json +++ b/src/lib/i18n/locales/th-TH/translation.json @@ -453,6 +453,7 @@ "Create a new note": "สร้างบันทึกใหม่", "Create Account": "สร้างบัญชี", "Create Admin Account": "สร้างบัญชีผู้ดูแลระบบ", + "Create and manage scheduled automations": "", "Create Channel": "สร้างช่องทาง", "Create Folder": "สร้างโฟลเดอร์", "Create Image": "สร้างรูปภาพ", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "การปรับแต่ง", "Pin": "ปักหมุด", + "Pin to Sidebar": "", "Pinned": "ปักหมุดแล้ว", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/tk-TM/translation.json b/src/lib/i18n/locales/tk-TM/translation.json index a39d4984cc..e4925558de 100644 --- a/src/lib/i18n/locales/tk-TM/translation.json +++ b/src/lib/i18n/locales/tk-TM/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Hasap döret", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "", "Pin": "", + "Pin to Sidebar": "", "Pinned": "", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/tr-TR/translation.json b/src/lib/i18n/locales/tr-TR/translation.json index 68f5afaba4..9dc681c490 100644 --- a/src/lib/i18n/locales/tr-TR/translation.json +++ b/src/lib/i18n/locales/tr-TR/translation.json @@ -454,6 +454,7 @@ "Create a new note": "Yeni bir not oluştur", "Create Account": "Hesap Oluştur", "Create Admin Account": "Yönetici Hesabı Oluştur", + "Create and manage scheduled automations": "", "Create Channel": "Kanal Oluştur", "Create Folder": "Klasör Oluştur", "Create Image": "Görsel Oluştur", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Kişiselleştirme", "Pin": "Sabitle", + "Pin to Sidebar": "", "Pinned": "Sabitlenmiş", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ug-CN/translation.json b/src/lib/i18n/locales/ug-CN/translation.json index 5301df3b2e..0b4b5191fa 100644 --- a/src/lib/i18n/locales/ug-CN/translation.json +++ b/src/lib/i18n/locales/ug-CN/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "ھېساب قۇرۇش", "Create Admin Account": "باشقۇرغۇچى ھېساباتى قۇرۇش", + "Create and manage scheduled automations": "", "Create Channel": "قانال قۇرۇش", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "شەخسىيلاشتۇرۇش", "Pin": "مۇقىملا", + "Pin to Sidebar": "", "Pinned": "مۇقىملاندى", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/uk-UA/translation.json b/src/lib/i18n/locales/uk-UA/translation.json index 4c2adc74c3..66b8c3815d 100644 --- a/src/lib/i18n/locales/uk-UA/translation.json +++ b/src/lib/i18n/locales/uk-UA/translation.json @@ -456,6 +456,7 @@ "Create a new note": "", "Create Account": "Створити обліковий запис", "Create Admin Account": "Створити обліковий запис адміністратора", + "Create and manage scheduled automations": "", "Create Channel": "Створити канал", "Create Folder": "", "Create Image": "", @@ -1515,6 +1516,7 @@ "Persistent": "", "Personalization": "Персоналізація", "Pin": "Зачепити", + "Pin to Sidebar": "", "Pinned": "Зачеплено", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/ur-PK/translation.json b/src/lib/i18n/locales/ur-PK/translation.json index 7a43ef0db5..46453a199f 100644 --- a/src/lib/i18n/locales/ur-PK/translation.json +++ b/src/lib/i18n/locales/ur-PK/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "اکاؤنٹ بنائیں", "Create Admin Account": "", + "Create and manage scheduled automations": "", "Create Channel": "", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "شخصی ترتیبات", "Pin": "پن", + "Pin to Sidebar": "", "Pinned": "پن کیا گیا", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json b/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json index b2928870f7..c10e020c66 100644 --- a/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json +++ b/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Ҳисоб яратиш", "Create Admin Account": "Администратор ҳисобини яратинг", + "Create and manage scheduled automations": "", "Create Channel": "Канал яратиш", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Шахсийлаштириш", "Pin": "Пин", + "Pin to Sidebar": "", "Pinned": "Қадалган", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/uz-Latn-Uz/translation.json b/src/lib/i18n/locales/uz-Latn-Uz/translation.json index 767dd2bb23..e768610977 100644 --- a/src/lib/i18n/locales/uz-Latn-Uz/translation.json +++ b/src/lib/i18n/locales/uz-Latn-Uz/translation.json @@ -454,6 +454,7 @@ "Create a new note": "", "Create Account": "Hisob yaratish", "Create Admin Account": "Administrator hisobini yarating", + "Create and manage scheduled automations": "", "Create Channel": "Kanal yaratish", "Create Folder": "", "Create Image": "", @@ -1513,6 +1514,7 @@ "Persistent": "", "Personalization": "Shaxsiylashtirish", "Pin": "Pin", + "Pin to Sidebar": "", "Pinned": "Qadalgan", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/vi-VN/translation.json b/src/lib/i18n/locales/vi-VN/translation.json index d2917e0c20..e57ef16df3 100644 --- a/src/lib/i18n/locales/vi-VN/translation.json +++ b/src/lib/i18n/locales/vi-VN/translation.json @@ -453,6 +453,7 @@ "Create a new note": "", "Create Account": "Tạo Tài khoản", "Create Admin Account": "Tạo Tài khoản Quản trị", + "Create and manage scheduled automations": "", "Create Channel": "Tạo Kênh", "Create Folder": "", "Create Image": "", @@ -1512,6 +1513,7 @@ "Persistent": "", "Personalization": "Cá nhân hóa", "Pin": "Ghim", + "Pin to Sidebar": "", "Pinned": "Đã ghim", "Pinned Messages": "", "Pinned Models": "", diff --git a/src/lib/i18n/locales/zh-CN/translation.json b/src/lib/i18n/locales/zh-CN/translation.json index 5146725806..d5c0a89340 100644 --- a/src/lib/i18n/locales/zh-CN/translation.json +++ b/src/lib/i18n/locales/zh-CN/translation.json @@ -453,6 +453,7 @@ "Create a new note": "新建笔记", "Create Account": "创建账号", "Create Admin Account": "创建管理员账号", + "Create and manage scheduled automations": "", "Create Channel": "创建频道", "Create Folder": "创建分组", "Create Image": "图片生成", @@ -1512,6 +1513,7 @@ "Persistent": "持久化", "Personalization": "个性化", "Pin": "置顶", + "Pin to Sidebar": "", "Pinned": "已置顶", "Pinned Messages": "置顶消息", "Pinned Models": "固定在侧边栏的模型", diff --git a/src/lib/i18n/locales/zh-TW/translation.json b/src/lib/i18n/locales/zh-TW/translation.json index d15e7b486f..73fafda1bf 100644 --- a/src/lib/i18n/locales/zh-TW/translation.json +++ b/src/lib/i18n/locales/zh-TW/translation.json @@ -453,6 +453,7 @@ "Create a new note": "新建筆記", "Create Account": "建立帳號", "Create Admin Account": "建立管理員帳號", + "Create and manage scheduled automations": "", "Create Channel": "建立頻道", "Create Folder": "建立分組", "Create Image": "產生圖片", @@ -1512,6 +1513,7 @@ "Persistent": "持久性", "Personalization": "個人化", "Pin": "釘選", + "Pin to Sidebar": "", "Pinned": "已釘選", "Pinned Messages": "置頂訊息", "Pinned Models": "固定於側邊欄的模型", From 18608741923a0f2b24979e85b8cfaead4c033c37 Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Wed, 15 Apr 2026 06:30:18 +0800 Subject: [PATCH 048/101] i18n: add missing i18n keys of ScheduleDropdown (#23726) --- .../automations/ScheduleDropdown.svelte | 33 ++++++++++--------- src/lib/i18n/locales/ar-BH/translation.json | 13 +++++++- src/lib/i18n/locales/ar/translation.json | 13 +++++++- src/lib/i18n/locales/az-AZ/translation.json | 13 +++++++- src/lib/i18n/locales/bg-BG/translation.json | 13 +++++++- src/lib/i18n/locales/bn-BD/translation.json | 13 +++++++- src/lib/i18n/locales/bo-TB/translation.json | 13 +++++++- src/lib/i18n/locales/bs-BA/translation.json | 13 +++++++- src/lib/i18n/locales/ca-ES/translation.json | 13 +++++++- src/lib/i18n/locales/ceb-PH/translation.json | 13 +++++++- src/lib/i18n/locales/cs-CZ/translation.json | 13 +++++++- src/lib/i18n/locales/da-DK/translation.json | 13 +++++++- src/lib/i18n/locales/de-DE/translation.json | 13 +++++++- src/lib/i18n/locales/dg-DG/translation.json | 13 +++++++- src/lib/i18n/locales/el-GR/translation.json | 13 +++++++- src/lib/i18n/locales/en-GB/translation.json | 13 +++++++- src/lib/i18n/locales/en-US/translation.json | 13 +++++++- src/lib/i18n/locales/es-ES/translation.json | 13 +++++++- src/lib/i18n/locales/et-EE/translation.json | 13 +++++++- src/lib/i18n/locales/eu-ES/translation.json | 13 +++++++- src/lib/i18n/locales/fa-IR/translation.json | 13 +++++++- src/lib/i18n/locales/fi-FI/translation.json | 13 +++++++- src/lib/i18n/locales/fr-CA/translation.json | 13 +++++++- src/lib/i18n/locales/fr-FR/translation.json | 13 +++++++- src/lib/i18n/locales/gl-ES/translation.json | 13 +++++++- src/lib/i18n/locales/he-IL/translation.json | 13 +++++++- src/lib/i18n/locales/hi-IN/translation.json | 13 +++++++- src/lib/i18n/locales/hr-HR/translation.json | 13 +++++++- src/lib/i18n/locales/hu-HU/translation.json | 13 +++++++- src/lib/i18n/locales/id-ID/translation.json | 13 +++++++- src/lib/i18n/locales/ie-GA/translation.json | 13 +++++++- src/lib/i18n/locales/it-IT/translation.json | 13 +++++++- src/lib/i18n/locales/ja-JP/translation.json | 13 +++++++- src/lib/i18n/locales/ka-GE/translation.json | 13 +++++++- src/lib/i18n/locales/kab-DZ/translation.json | 13 +++++++- src/lib/i18n/locales/ko-KR/translation.json | 13 +++++++- src/lib/i18n/locales/lt-LT/translation.json | 13 +++++++- src/lib/i18n/locales/lv-LV/translation.json | 13 +++++++- src/lib/i18n/locales/ms-MY/translation.json | 13 +++++++- src/lib/i18n/locales/nb-NO/translation.json | 13 +++++++- src/lib/i18n/locales/nl-NL/translation.json | 13 +++++++- src/lib/i18n/locales/pa-IN/translation.json | 13 +++++++- src/lib/i18n/locales/pl-PL/translation.json | 13 +++++++- src/lib/i18n/locales/pt-BR/translation.json | 13 +++++++- src/lib/i18n/locales/pt-PT/translation.json | 13 +++++++- src/lib/i18n/locales/ro-RO/translation.json | 13 +++++++- src/lib/i18n/locales/ru-RU/translation.json | 13 +++++++- src/lib/i18n/locales/sk-SK/translation.json | 13 +++++++- src/lib/i18n/locales/sr-RS/translation.json | 13 +++++++- src/lib/i18n/locales/sv-SE/translation.json | 13 +++++++- src/lib/i18n/locales/ta-IN/translation.json | 13 +++++++- src/lib/i18n/locales/th-TH/translation.json | 13 +++++++- src/lib/i18n/locales/tk-TM/translation.json | 13 +++++++- src/lib/i18n/locales/tr-TR/translation.json | 13 +++++++- src/lib/i18n/locales/ug-CN/translation.json | 13 +++++++- src/lib/i18n/locales/uk-UA/translation.json | 13 +++++++- src/lib/i18n/locales/ur-PK/translation.json | 13 +++++++- .../i18n/locales/uz-Cyrl-UZ/translation.json | 13 +++++++- .../i18n/locales/uz-Latn-Uz/translation.json | 13 +++++++- src/lib/i18n/locales/vi-VN/translation.json | 13 +++++++- src/lib/i18n/locales/zh-CN/translation.json | 13 +++++++- src/lib/i18n/locales/zh-TW/translation.json | 13 +++++++- 62 files changed, 749 insertions(+), 77 deletions(-) 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 @@
@@ -77,10 +68,12 @@ { - builtinTools = { - ...builtinTools, - [tool]: e.detail === 'checked' - }; + if (e.detail === 'checked') { + delete builtinTools[tool]; + } else { + builtinTools[tool] = false; + } + builtinTools = builtinTools; }} /> From e5f31c2e14058228769158d2a1f654e3cecdb05a Mon Sep 17 00:00:00 2001 From: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:22:06 +0300 Subject: [PATCH 094/101] perf: replace JSON.stringify equality with fast-deep-equal (#23370) --- package-lock.json | 2 +- package.json | 1 + .../components/chat/Messages/MultiResponseMessages.svelte | 3 ++- src/lib/components/chat/Messages/ResponseMessage.svelte | 3 ++- .../chat/Messages/ResponseMessage/StatusHistory.svelte | 6 ++---- src/lib/components/chat/Messages/UserMessage.svelte | 3 ++- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 538792470e..e917faa608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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": { diff --git a/package.json b/package.json index ab0cf78afc..204f35d51f 100644 --- a/package.json +++ b/package.json @@ -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/src/lib/components/chat/Messages/MultiResponseMessages.svelte b/src/lib/components/chat/Messages/MultiResponseMessages.svelte index ae2be93ff2..a6d5a9b8a7 100644 --- a/src/lib/components/chat/Messages/MultiResponseMessages.svelte +++ b/src/lib/components/chat/Messages/MultiResponseMessages.svelte @@ -19,6 +19,7 @@ import localizedFormat from 'dayjs/plugin/localizedFormat'; import ProfileImage from './ProfileImage.svelte'; import { WEBUI_BASE_URL } from '$lib/constants'; + import equal from 'fast-deep-equal'; const i18n = getContext('i18n'); dayjs.extend(localizedFormat); @@ -66,7 +67,7 @@ if (source) { if (message.content !== source.content || message.done !== source.done) { 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/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 35e592d139..ebae97d95b 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -37,6 +37,7 @@ removeAllDetails } 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'; @@ -127,7 +128,7 @@ // Avoids 2x O(n) JSON.stringify calls that are always true during streaming anyway if (message.content !== source.content || message.done !== source.done) { message = structuredClone(source); - } else if (JSON.stringify(message) !== JSON.stringify(source)) { + } else if (!equal(message, source)) { // Slow path: full comparison for infrequent changes (sources, annotations, status, etc.) message = structuredClone(source); } diff --git a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte index 5aaf774694..bb65bf331b 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte @@ -3,6 +3,7 @@ const i18n = getContext('i18n'); import StatusItem from './StatusHistory/StatusItem.svelte'; + import equal from 'fast-deep-equal'; export let statusHistory = []; export let expand = false; @@ -21,10 +22,7 @@ status = history.at(-1); } - $: if ( - statusHistory.length !== history.length || - JSON.stringify(statusHistory) !== JSON.stringify(history) - ) { + $: if (!equal(statusHistory, history)) { history = statusHistory; } 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); } } From e8e655d0de1c39d0131c0297cf5ac41870a7cfd9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 17 Apr 2026 14:24:34 +0900 Subject: [PATCH 095/101] chore: dep bump --- package-lock.json | 205 +++++++++++++++++++++++----------------------- 1 file changed, 103 insertions(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index e917faa608..3e52640600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,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": { @@ -1248,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": { @@ -1374,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": { @@ -3584,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", @@ -3612,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": { @@ -5390,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" @@ -5620,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" @@ -5964,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": { @@ -6369,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": { @@ -7744,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" @@ -8239,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": { @@ -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" From 4113b15a60771a8945319ef48e1c3c9fac5f3645 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 17 Apr 2026 14:28:18 +0900 Subject: [PATCH 096/101] chore: format --- .github/pull_request_template.md | 1 - .../c1d2e3f4a5b6_add_shared_chat_table.py | 74 +++++++++---------- backend/open_webui/models/chats.py | 4 +- backend/open_webui/models/prompts.py | 8 +- backend/open_webui/models/shared_chats.py | 29 ++------ backend/open_webui/retrieval/utils.py | 2 +- backend/open_webui/routers/memories.py | 11 +-- backend/open_webui/routers/retrieval.py | 1 - .../utils/access_control/__init__.py | 5 +- backend/open_webui/utils/middleware.py | 61 ++++++++++++--- backend/open_webui/utils/models.py | 1 - package-lock.json | 4 +- package.json | 2 +- src/lib/apis/chats/index.ts | 6 +- src/lib/components/chat/MessageInput.svelte | 21 ++++-- src/lib/components/chat/ShareChatModal.svelte | 6 +- .../workspace/Models/ModelEditor.svelte | 8 +- src/lib/i18n/locales/ar-BH/translation.json | 6 ++ src/lib/i18n/locales/ar/translation.json | 6 ++ src/lib/i18n/locales/az-AZ/translation.json | 6 ++ src/lib/i18n/locales/bg-BG/translation.json | 6 ++ src/lib/i18n/locales/bn-BD/translation.json | 6 ++ src/lib/i18n/locales/bo-TB/translation.json | 6 ++ src/lib/i18n/locales/bs-BA/translation.json | 6 ++ src/lib/i18n/locales/ca-ES/translation.json | 6 ++ src/lib/i18n/locales/ceb-PH/translation.json | 6 ++ src/lib/i18n/locales/cs-CZ/translation.json | 6 ++ src/lib/i18n/locales/da-DK/translation.json | 6 ++ src/lib/i18n/locales/de-DE/translation.json | 6 ++ src/lib/i18n/locales/dg-DG/translation.json | 6 ++ src/lib/i18n/locales/el-GR/translation.json | 6 ++ src/lib/i18n/locales/en-GB/translation.json | 6 ++ src/lib/i18n/locales/en-US/translation.json | 6 ++ src/lib/i18n/locales/es-ES/translation.json | 6 ++ src/lib/i18n/locales/et-EE/translation.json | 6 ++ src/lib/i18n/locales/eu-ES/translation.json | 6 ++ src/lib/i18n/locales/fa-IR/translation.json | 6 ++ src/lib/i18n/locales/fi-FI/translation.json | 6 ++ src/lib/i18n/locales/fr-CA/translation.json | 6 ++ src/lib/i18n/locales/fr-FR/translation.json | 6 ++ src/lib/i18n/locales/gl-ES/translation.json | 6 ++ src/lib/i18n/locales/he-IL/translation.json | 6 ++ src/lib/i18n/locales/hi-IN/translation.json | 6 ++ src/lib/i18n/locales/hr-HR/translation.json | 6 ++ src/lib/i18n/locales/hu-HU/translation.json | 6 ++ src/lib/i18n/locales/id-ID/translation.json | 6 ++ src/lib/i18n/locales/ie-GA/translation.json | 8 ++ src/lib/i18n/locales/it-IT/translation.json | 6 ++ src/lib/i18n/locales/ja-JP/translation.json | 6 ++ src/lib/i18n/locales/ka-GE/translation.json | 6 ++ src/lib/i18n/locales/kab-DZ/translation.json | 6 ++ src/lib/i18n/locales/ko-KR/translation.json | 6 ++ src/lib/i18n/locales/lt-LT/translation.json | 6 ++ src/lib/i18n/locales/lv-LV/translation.json | 6 ++ src/lib/i18n/locales/ms-MY/translation.json | 6 ++ src/lib/i18n/locales/nb-NO/translation.json | 6 ++ src/lib/i18n/locales/nl-NL/translation.json | 6 ++ src/lib/i18n/locales/pa-IN/translation.json | 6 ++ src/lib/i18n/locales/pl-PL/translation.json | 6 ++ src/lib/i18n/locales/pt-BR/translation.json | 6 ++ src/lib/i18n/locales/pt-PT/translation.json | 6 ++ src/lib/i18n/locales/ro-RO/translation.json | 6 ++ src/lib/i18n/locales/ru-RU/translation.json | 6 ++ src/lib/i18n/locales/sk-SK/translation.json | 6 ++ src/lib/i18n/locales/sr-RS/translation.json | 6 ++ src/lib/i18n/locales/sv-SE/translation.json | 6 ++ src/lib/i18n/locales/ta-IN/translation.json | 6 ++ src/lib/i18n/locales/th-TH/translation.json | 6 ++ src/lib/i18n/locales/tk-TM/translation.json | 6 ++ src/lib/i18n/locales/tr-TR/translation.json | 6 ++ src/lib/i18n/locales/ug-CN/translation.json | 6 ++ src/lib/i18n/locales/uk-UA/translation.json | 6 ++ src/lib/i18n/locales/ur-PK/translation.json | 6 ++ .../i18n/locales/uz-Cyrl-UZ/translation.json | 6 ++ .../i18n/locales/uz-Latn-Uz/translation.json | 6 ++ src/lib/i18n/locales/vi-VN/translation.json | 6 ++ src/lib/i18n/locales/zh-CN/translation.json | 6 ++ src/lib/i18n/locales/zh-TW/translation.json | 6 ++ src/lib/utils/index.ts | 6 +- 79 files changed, 501 insertions(+), 117 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2dfe189f0d..ad311a371a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,6 @@ This is to ensure large feature PRs are discussed with the community first, befo The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase. --> - **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/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py index ca2f9e7cd3..2451f50ae2 100644 --- a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py +++ b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py @@ -91,41 +91,41 @@ def upgrade(): 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() + 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, - )) + 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()), - )) + 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-%')) - ) + 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-%'))) @@ -147,18 +147,18 @@ def downgrade(): ).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( + 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') - ) + 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/models/chats.py b/backend/open_webui/models/chats.py index be65a92c91..ba6611a811 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -723,9 +723,7 @@ class ChatTable: """Delegate to SharedChats for listing shared chats by user.""" from open_webui.models.shared_chats import SharedChats - return await SharedChats.get_by_user_id( - user_id, filter=filter, skip=skip, limit=limit, db=db - ) + 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, diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 808073a458..dec1848aa7 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -299,15 +299,17 @@ class PromptsTable: if dialect_name == 'sqlite': tag_clause = text( - "EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)" + '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)" + 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' ) else: # 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_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: diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py index 1a042922fb..37a3fea852 100644 --- a/backend/open_webui/models/shared_chats.py +++ b/backend/open_webui/models/shared_chats.py @@ -60,9 +60,7 @@ class SharedChatResponse(BaseModel): class SharedChatsTable: - async def create( - self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + 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. @@ -92,9 +90,7 @@ class SharedChatsTable: return SharedChatModel.model_validate(shared_chat) - async def update( - self, share_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + 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. """ @@ -117,9 +113,7 @@ class SharedChatsTable: 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]: + 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) @@ -127,16 +121,11 @@ class SharedChatsTable: return SharedChatModel.model_validate(shared_chat) return None - async def get_by_chat_id( - self, chat_id: str, db: Optional[AsyncSession] = None - ) -> Optional[SharedChatModel]: + 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) + select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1) ) shared_chat = result.scalars().first() if shared_chat: @@ -194,9 +183,7 @@ class SharedChatsTable: for sc in result.scalars().all() ] - async def delete_by_id( - self, share_id: str, db: Optional[AsyncSession] = None - ) -> bool: + 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: @@ -206,9 +193,7 @@ class SharedChatsTable: except Exception: return False - async def delete_by_chat_id( - self, chat_id: str, db: Optional[AsyncSession] = None - ) -> bool: + 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: diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 07c1ae5210..93ba72ce13 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -959,7 +959,7 @@ async def filter_accessible_collections( # System meta-collection — never exposed to non-admins. continue elif name.startswith('file-'): - file_id = name[len('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-'): diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index b275c84e29..6522118258 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -150,15 +150,8 @@ async def query_memory( # 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] - ): + 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 = [] diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 85686c4fcf..ee8a9007fe 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -2365,7 +2365,6 @@ async def _validate_collection_access(collection_names: list[str], user, access_ ) - class QueryDocForm(BaseModel): collection_name: str query: str diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 7eba61770e..06e8d0aba0 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -339,11 +339,8 @@ 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 - ): + 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/middleware.py b/backend/open_webui/utils/middleware.py index 2ed08fedab..404e36cfa3 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -452,6 +452,50 @@ def serialize_output(output: list) -> str: # Already handled inline with function_call above pass + elif item_type in ('web_search_call', 'file_search_call', 'computer_call'): + # OpenAI Responses API built-in server-side tool output items. + # These are emitted when the model uses native tools (web_search, + # file_search, computer_use) through the Responses API. Render as + # collapsible tool call blocks matching the function_call pattern. + if content and not content.endswith('\n'): + content += '\n' + + call_id = item.get('id', '') + status = item.get('status', 'in_progress') + + # Derive a human-readable display name + display_names = { + 'web_search_call': 'Web Search', + 'file_search_call': 'File Search', + 'computer_call': 'Computer Use', + } + display_name = display_names.get(item_type, item_type) + + # Extract a summary of what the tool did for the details body + summary_text = '' + if item_type == 'web_search_call': + action = item.get('action', {}) + if isinstance(action, dict): + query = action.get('query', '') + if query: + summary_text = f'Query: {query}' + elif item_type == 'file_search_call': + queries = item.get('queries', []) + if queries: + summary_text = f'Queries: {", ".join(str(q) for q in queries)}' + elif item_type == 'computer_call': + action = item.get('action', {}) + if isinstance(action, dict): + action_type = action.get('type', '') + if action_type: + summary_text = f'Action: {action_type}' + + done = status == 'completed' or idx != len(output) - 1 + if done: + content += f'
\nTool Executed\n{html.escape(summary_text)}\n
\n' + else: + content += f'
\nExecuting...\n
\n' + elif item_type == 'reasoning': reasoning_content = '' # Check for 'summary' (new structure) or 'content' (legacy/fallback) @@ -2619,9 +2663,7 @@ 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) - terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( - 'terminal', True - ) + 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( @@ -3110,11 +3152,13 @@ async def outlet_filter_handler(ctx): # Append the full assistant message (content, output, usage, etc.) if assistant_message: - message_list.append({ - 'id': message_id, - 'role': 'assistant', - **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: @@ -4221,7 +4265,6 @@ async def streaming_chat_response_handler(response, ctx): ) reasoning_item['status'] = 'completed' - if response_tool_calls: tool_calls.append(_split_tool_calls(response_tool_calls)) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 33642a339d..6b12515ba1 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -413,7 +413,6 @@ async def check_model_access(user, model, db=None): 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 if ( diff --git a/package-lock.json b/package-lock.json index 3e52640600..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", diff --git a/package.json b/package.json index 204f35d51f..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", diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 751fc823f2..028371386c 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -953,11 +953,7 @@ export const deleteSharedChatById = async (token: string, id: string) => { return res; }; -export const updateChatAccessGrants = async ( - token: string, - id: string, - accessGrants: object[] -) => { +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`, { diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 041c511493..92de0c3d43 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -496,11 +496,8 @@ ); let terminalCapableModels = []; - $: terminalCapableModels = ( - atSelectedModel?.id ? [atSelectedModel.id] : selectedModels - ).filter( - (model) => - $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true + $: terminalCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter( + (model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true ); let toggleFilters = []; @@ -1760,12 +1757,18 @@