Merge branch 'dev' into feat/rds-iam-support

This commit is contained in:
Brendan Shanahan 2026-04-17 13:30:34 -04:00 committed by GitHub
commit acad321c83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 5578 additions and 2206 deletions

View file

@ -10,6 +10,12 @@
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
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.**

24
LICENSE
View file

@ -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

View file

@ -1,3 +1,4 @@
import asyncio
import json
import logging
import os
@ -35,7 +36,7 @@ from open_webui.env import (
WEBUI_NAME,
log,
)
from open_webui.internal.db import Base, get_db
from open_webui.internal.db import Base, get_db, get_async_db
from open_webui.utils.redis import get_redis_connection
@ -90,6 +91,7 @@ def load_json_config():
def save_to_db(data):
"""Sync save — used ONLY at startup/import time."""
with get_db() as db:
existing_config = db.query(Config).first()
if not existing_config:
@ -102,12 +104,39 @@ def save_to_db(data):
db.commit()
async def async_save_to_db(data):
"""Async save — used for ALL runtime config persistence."""
from sqlalchemy import select
async with get_async_db() as db:
result = await db.execute(select(Config).limit(1))
existing_config = result.scalars().first()
if not existing_config:
new_config = Config(data=data, version=0)
db.add(new_config)
else:
existing_config.data = data
existing_config.updated_at = datetime.now()
db.add(existing_config)
await db.commit()
def reset_config():
"""Sync reset — used ONLY at startup."""
with get_db() as db:
db.query(Config).delete()
db.commit()
async def async_reset_config():
"""Async reset — used at runtime."""
from sqlalchemy import delete as sa_delete
async with get_async_db() as db:
await db.execute(sa_delete(Config))
await db.commit()
# When initializing, check if config.json exists and migrate it to the database
if os.path.exists(f'{DATA_DIR}/config.json'):
data = load_json_config()
@ -144,6 +173,7 @@ PERSISTENT_CONFIG_REGISTRY = []
def save_config(config):
"""Sync save — used ONLY at startup/import time."""
global CONFIG_DATA
global PERSISTENT_CONFIG_REGISTRY
try:
@ -159,6 +189,23 @@ def save_config(config):
return True
async def async_save_config(config):
"""Async save — used for ALL runtime config persistence."""
global CONFIG_DATA
global PERSISTENT_CONFIG_REGISTRY
try:
await async_save_to_db(config)
CONFIG_DATA = config
# Trigger updates on all registered PersistentConfig entries
for config_item in PERSISTENT_CONFIG_REGISTRY:
config_item.update()
except Exception as e:
log.exception(e)
return False
return True
T = TypeVar('T')
ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true'
@ -202,6 +249,7 @@ class PersistentConfig(Generic[T]):
log.info(f'Updated {self.env_name} to new value {self.value}')
def save(self):
"""Sync save — used ONLY at startup/import time."""
log.info(f"Saving '{self.env_name}' to the database")
path_parts = self.config_path.split('.')
sub_config = CONFIG_DATA
@ -213,6 +261,19 @@ class PersistentConfig(Generic[T]):
save_to_db(CONFIG_DATA)
self.config_value = self.value
async def async_save(self):
"""Async save — used for ALL runtime config persistence."""
log.info(f"Saving '{self.env_name}' to the database")
path_parts = self.config_path.split('.')
sub_config = CONFIG_DATA
for key in path_parts[:-1]:
if key not in sub_config:
sub_config[key] = {}
sub_config = sub_config[key]
sub_config[path_parts[-1]] = self.value
await async_save_to_db(CONFIG_DATA)
self.config_value = self.value
class AppConfig:
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
@ -246,12 +307,27 @@ class AppConfig:
self._state[key] = value
else:
self._state[key].value = value
self._state[key].save()
# At runtime (inside the event loop) persist via the async engine
# to avoid blocking the loop and contending with the async DB pool.
# At startup/import time, fall back to sync.
try:
loop = asyncio.get_running_loop()
loop.create_task(self._async_persist(key))
except RuntimeError:
self._state[key].save()
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f'{self._redis_key_prefix}:config:{key}'
self._redis.set(redis_key, json.dumps(self._state[key].value))
async def _async_persist(self, key):
"""Persist a single config key via the async engine."""
try:
await self._state[key].async_save()
except Exception as e:
log.error(f'Failed to async-persist config key {key}: {e}')
def __getattr__(self, key):
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
@ -1149,10 +1225,16 @@ ENABLE_SIGNUP = PersistentConfig(
ENABLE_LOGIN_FORM = PersistentConfig(
'ENABLE_LOGIN_FORM',
'ui.ENABLE_LOGIN_FORM',
'ui.enable_login_form',
os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true',
)
ENABLE_PASSWORD_CHANGE_FORM = PersistentConfig(
'ENABLE_PASSWORD_CHANGE_FORM',
'ui.enable_password_change_form',
os.environ.get('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true',
)
ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true'
DEFAULT_LOCALE = PersistentConfig(
@ -2883,6 +2965,12 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true'
)
RAG_RERANKING_BATCH_SIZE = PersistentConfig(
'RAG_RERANKING_BATCH_SIZE',
'rag.reranking_batch_size',
int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')),
)
RAG_EXTERNAL_RERANKER_URL = PersistentConfig(
'RAG_EXTERNAL_RERANKER_URL',
'rag.external_reranker_url',

View file

@ -91,6 +91,17 @@ class ERROR_MESSAGES(str, Enum):
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})'
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
class TASKS(str, Enum):
def __str__(self) -> str:

View file

@ -397,7 +397,35 @@ else:
except Exception:
DATABASE_POOL_RECYCLE = 3600
DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'False').lower() == 'true'
DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'True').lower() == 'true'
# SQLite PRAGMA tuning — these defaults are optimised for WAL-mode web-server
# workloads. Each can be overridden via its environment variable.
# Set any value to an empty string to skip that PRAGMA entirely.
# PRAGMA synchronous: NORMAL (1) is safe with WAL and avoids an fsync per
# transaction. Valid values: OFF (0), NORMAL (1), FULL (2), EXTRA (3).
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS = os.environ.get('DATABASE_SQLITE_PRAGMA_SYNCHRONOUS', 'NORMAL')
# PRAGMA busy_timeout (ms): how long a connection waits for a write lock
# before raising SQLITE_BUSY.
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT = os.environ.get('DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT', '5000')
# PRAGMA cache_size: negative value = KiB. -65536 ≈ 64 MB page cache.
DATABASE_SQLITE_PRAGMA_CACHE_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_CACHE_SIZE', '-65536')
# PRAGMA temp_store: MEMORY (2) keeps temp tables and indices in RAM.
# Valid values: DEFAULT (0), FILE (1), MEMORY (2).
DATABASE_SQLITE_PRAGMA_TEMP_STORE = os.environ.get('DATABASE_SQLITE_PRAGMA_TEMP_STORE', 'MEMORY')
# PRAGMA mmap_size (bytes): memory-mapped I/O size. 268435456 ≈ 256 MB.
# Set to 0 to disable mmap.
DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', '268435456')
# PRAGMA journal_size_limit (bytes): caps the WAL file size after checkpoint.
# Without this the WAL grows unbounded during write bursts and is never
# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default).
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.environ.get('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864')
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None)
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
@ -539,6 +567,11 @@ PASSWORD_VALIDATION_HINT = os.environ.get('PASSWORD_VALIDATION_HINT', '')
BYPASS_MODEL_ACCESS_CONTROL = os.environ.get('BYPASS_MODEL_ACCESS_CONTROL', 'False').lower() == 'true'
# When enabled, skips pydub-based preprocessing (format conversion, compression,
# and chunked splitting) before sending files to processing engines. Useful when
# the upstream provider handles these steps or when ffmpeg is unavailable.
BYPASS_PYDUB_PREPROCESSING = os.environ.get('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true'
# When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path})
# is blocked. Enable only if you need direct passthrough to upstream OpenAI-
# compatible APIs for endpoints not natively handled by Open WebUI.

View file

@ -34,8 +34,10 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import check_model_access
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
@ -260,6 +262,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if model_info.base_model_id:
form_data['model'] = model_info.base_model_id
if not BYPASS_MODEL_ACCESS_CONTROL:
bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass)
params = model_info.params.model_dump()
if params:

View file

@ -24,6 +24,12 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
@ -370,14 +376,32 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
def on_connect(dbapi_connection, connection_record):
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
cursor = dbapi_connection.cursor()
if DATABASE_ENABLE_SQLITE_WAL:
cursor.execute('PRAGMA journal_mode=WAL')
else:
cursor.execute('PRAGMA journal_mode=DELETE')
# Each PRAGMA is skipped when its env var is empty, allowing opt-out.
if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS:
cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}')
if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT:
cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}')
if DATABASE_SQLITE_PRAGMA_CACHE_SIZE:
cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}')
if DATABASE_SQLITE_PRAGMA_TEMP_STORE:
cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}')
if DATABASE_SQLITE_PRAGMA_MMAP_SIZE:
cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}')
if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT:
cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}')
cursor.close()
def on_connect(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
event.listen(engine, 'connect', on_connect)
elif DATABASE_ENABLE_IAM_TOKEN_AUTH:
engine = rds_iam_config.create_engine()
@ -416,15 +440,10 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
ASYNC_SQLALCHEMY_DATABASE_URL,
connect_args={'check_same_thread': False},
)
if DATABASE_ENABLE_SQLITE_WAL:
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_wal(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute('PRAGMA journal_mode=WAL')
cursor.close()
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
elif DATABASE_ENABLE_IAM_TOKEN_AUTH:
async_engine = rds_iam_config.create_engine(is_async=True)
event.listen(async_engine.sync_engine, 'do_connect', rds_iam_config.check_token)

View file

@ -21,7 +21,7 @@ from typing import Optional
from aiocache import cached
import aiohttp
import anyio.to_thread
import requests
from redis import Redis
@ -46,7 +46,6 @@ from fastapi.staticfiles import StaticFiles
from starlette_compress import CompressMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers
@ -58,8 +57,15 @@ from starsessions import (
from starsessions.stores.redis import RedisStore
from open_webui.utils import logger
from open_webui.utils.asgi_middleware import (
AuthTokenMiddleware,
CommitSessionMiddleware,
RedirectMiddleware,
WebsocketUpgradeGuardMiddleware,
)
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.logger import start_logger
from open_webui.utils.session_pool import get_session
from open_webui.socket.main import (
MODELS,
app as socket_app,
@ -115,7 +121,7 @@ from open_webui.internal.db import ScopedSession, engine, get_async_session
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.models.chats import Chats, ChatForm
from open_webui.config import (
# Ollama
@ -242,6 +248,7 @@ from open_webui.config import (
RAG_EXTERNAL_RERANKER_URL,
RAG_EXTERNAL_RERANKER_API_KEY,
RAG_EXTERNAL_RERANKER_TIMEOUT,
RAG_RERANKING_BATCH_SIZE,
RAG_RERANKING_MODEL_AUTO_UPDATE,
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE,
@ -379,6 +386,7 @@ from open_webui.config import (
JWT_EXPIRES_IN,
ENABLE_SIGNUP,
ENABLE_LOGIN_FORM,
ENABLE_PASSWORD_CHANGE_FORM,
ENABLE_API_KEYS,
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEYS_ALLOWED_ENDPOINTS,
@ -470,6 +478,7 @@ from open_webui.config import (
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
AppConfig,
reset_config,
async_reset_config,
)
from open_webui.env import (
ENABLE_CUSTOM_MODEL_FALLBACK,
@ -555,6 +564,7 @@ from open_webui.utils.oauth import (
get_oauth_client_info_with_static_credentials,
encrypt_data,
decrypt_data,
resolve_oauth_client_info,
OAuthManager,
OAuthClientManager,
OAuthClientInformationFull,
@ -574,7 +584,7 @@ from open_webui.tasks import (
from open_webui.utils.redis import get_sentinels_from_env
from open_webui.constants import ERROR_MESSAGES
from open_webui.constants import ERROR_MESSAGES, TASKS
if SAFE_MODE:
print('SAFE MODE ENABLED')
@ -625,7 +635,7 @@ async def lifespan(app: FastAPI):
start_logger()
if RESET_CONFIG_ON_START:
reset_config()
await async_reset_config()
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
@ -853,6 +863,7 @@ app.state.BASE_MODELS = []
app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
app.state.config.ENABLE_PASSWORD_CHANGE_FORM = ENABLE_PASSWORD_CHANGE_FORM
app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS
app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
@ -1034,6 +1045,7 @@ app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT
app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
@ -1183,6 +1195,7 @@ app.state.RERANKING_FUNCTION = get_reranking_function(
app.state.config.RAG_RERANKING_ENGINE,
app.state.config.RAG_RERANKING_MODEL,
reranking_function=app.state.rf,
reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE,
)
########################################
@ -1354,104 +1367,19 @@ if ENABLE_COMPRESSION_MIDDLEWARE:
app.add_middleware(CompressMiddleware)
class RedirectMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Check if the request is a GET request
if request.method == 'GET':
path = request.url.path
query_params = dict(parse_qs(urlparse(str(request.url)).query))
redirect_params = {}
# Check for the specific watch path and the presence of 'v' parameter
if path.endswith('/watch') and 'v' in query_params:
# Extract the first 'v' parameter
youtube_video_id = query_params['v'][0]
redirect_params['youtube'] = youtube_video_id
if 'shared' in query_params and len(query_params['shared']) > 0:
# PWA share_target support
text = query_params['shared'][0]
if text:
urls = re.match(r'https://\S+', text)
if urls:
from open_webui.retrieval.loaders.youtube import _parse_video_id
if youtube_video_id := _parse_video_id(urls[0]):
redirect_params['youtube'] = youtube_video_id
else:
redirect_params['load-url'] = urls[0]
else:
redirect_params['q'] = text
if redirect_params:
redirect_url = f'/?{urlencode(redirect_params)}'
return RedirectResponse(url=redirect_url)
# Proceed with the normal flow of other requests
response = await call_next(request)
return response
# All HTTP middlewares below are pure-ASGI implementations. The previous
# `BaseHTTPMiddleware` / `@app.middleware('http')` versions wrapped the
# downstream app in an anyio task group whose cancel scope cancelled
# in-flight DB calls (and any other awaits) on client disconnect /
# response completion — which surfaced as noisy SQLAlchemy
# `terminate_force_close` tracebacks under aiosqlite and as random
# CancelledError storms across the request path. See
# `open_webui.utils.asgi_middleware` for the rationale.
app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@app.middleware('http')
async def commit_session_after_request(request: Request, call_next):
response = await call_next(request)
# log.debug("Commit session after request")
try:
ScopedSession.commit()
finally:
# CRITICAL: remove() returns the connection to the pool.
# Without this, connections remain "checked out" and accumulate
# as "idle in transaction" in PostgreSQL.
ScopedSession.remove()
return response
@app.middleware('http')
async def check_url(request: Request, call_next):
start_time = int(time.time())
request.state.token = get_http_authorization_cred(request.headers.get('Authorization'))
# Fallback to cookie token for browser sessions
if request.state.token is None and request.cookies.get('token'):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=request.cookies.get('token'))
# Fallback to x-api-key header for Anthropic Messages API routes
if request.state.token is None and request.headers.get('x-api-key'):
request_path = request.url.path
if request_path in ('/api/message', '/api/v1/messages') or request_path.startswith('/ollama/v1/messages'):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme='Bearer', credentials=request.headers.get('x-api-key')
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
process_time = int(time.time()) - start_time
response.headers['X-Process-Time'] = str(process_time)
return response
@app.middleware('http')
async def inspect_websocket(request: Request, call_next):
if '/ws/socket.io' in request.url.path and request.query_params.get('transport') == 'websocket':
upgrade = (request.headers.get('Upgrade') or '').lower()
connection = (request.headers.get('Connection') or '').lower().split(',')
# Check that there's the correct headers for an upgrade, else reject the connection
# This is to work around this upstream issue: https://github.com/miguelgrinberg/python-engineio/issues/367
if upgrade != 'websocket' or 'upgrade' not in connection:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'detail': 'Invalid WebSocket upgrade request'},
)
return await call_next(request)
app.add_middleware(CommitSessionMiddleware)
app.add_middleware(AuthTokenMiddleware, fastapi_app=app)
app.add_middleware(WebsocketUpgradeGuardMiddleware)
app.add_middleware(
@ -1691,13 +1619,30 @@ async def chat_completion(
if model_info_params.get('reasoning_tags') is not None:
reasoning_tags = model_info_params.get('reasoning_tags')
# parent_id signals intent:
# null → new chat (root message, no parent)
# value → follow-up (user message's parentId = prev assistant)
# absent → legacy caller, no chat management
is_new_chat = 'parent_id' in form_data and form_data['parent_id'] is None and not form_data.get('chat_id')
parent_id = form_data.pop('parent_id', None)
form_data.pop('new_chat', None) # Legacy field
# Multi-model: {model_id: assistant_message_id}
# Single-model fallback: built from 'model' + 'id'
message_ids = form_data.pop('message_ids', None)
if not message_ids:
message_ids = {model_id: form_data.pop('id', None)}
else:
form_data.pop('id', None)
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
metadata = {
'user_id': user.id,
'chat_id': form_data.pop('chat_id', None),
'message_id': form_data.pop('id', None),
'parent_message': form_data.pop('parent_message', None),
'parent_message_id': form_data.pop('parent_id', None),
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
'session_id': form_data.pop('session_id', None),
'folder_id': form_data.pop('folder_id', None),
'filter_ids': form_data.pop('filter_ids', []),
'tool_ids': form_data.get('tool_ids', None),
'tool_servers': form_data.pop('tool_servers', None),
@ -1720,40 +1665,166 @@ async def chat_completion(
},
}
if is_new_chat:
metadata['chat_id'] = str(uuid4())
if metadata.get('chat_id') and user:
if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored
# Verify chat ownership — lightweight EXISTS check avoids
# deserializing the full chat JSON blob just to confirm the row exists
if (
not await Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin'
): # admins can access any chat
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
chat_id = metadata['chat_id']
if not chat_id.startswith('local:'): # temporary chats are not stored
if is_new_chat:
# Build the full history upfront with ALL assistant placeholders
user_message = metadata.get('user_message') or {}
user_message_id = user_message.get('id') if user_message else None
history_messages = {}
all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id]
if user_message_id and user_message:
user_message['childrenIds'] = all_assistant_ids
history_messages[user_message_id] = user_message
for target_model_id, assistant_message_id in message_ids.items():
if assistant_message_id:
history_messages[assistant_message_id] = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
}
await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'New Chat',
'models': list(message_ids.keys()),
'history': {
'currentId': all_assistant_ids[0] if all_assistant_ids else user_message_id,
'messages': history_messages,
},
'messages': [
{'role': 'user', 'content': user_message.get('content', '')},
]
if user_message_id
else [],
'tags': [],
'timestamp': int(time.time() * 1000),
},
folder_id=metadata.get('folder_id'),
),
)
# Insert chat files from parent message if any
parent_message = metadata.get('parent_message') or {}
parent_message_files = parent_message.get('files', [])
if parent_message_files:
try:
await Chats.insert_chat_files(
metadata['chat_id'],
parent_message.get('id'),
[
file_item.get('id')
for file_item in parent_message_files
if file_item.get('type') == 'file'
],
user.id,
# Insert chat files from user message if any
user_message_files = user_message.get('files', [])
if user_message_files:
try:
await Chats.insert_chat_files(
chat_id,
user_message_id,
[
file_item.get('id')
for file_item in user_message_files
if file_item.get('type') == 'file'
],
user.id,
)
except Exception as e:
log.debug(f'Error inserting chat files: {e}')
pass
else:
# Existing chat — verify ownership
if not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
except Exception as e:
log.debug(f'Error inserting chat files: {e}')
pass
# Save user message to DB
user_message = metadata.get('user_message') or {}
if user_message and user_message.get('id'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
user_message['id'],
user_message,
)
# Link grandparent → user message (childrenIds)
grandparent_id = user_message.get('parentId')
if grandparent_id:
grandparent = await Chats.get_message_by_id_and_message_id(chat_id, grandparent_id)
if grandparent:
child_ids = grandparent.get('childrenIds', [])
if user_message['id'] not in child_ids:
child_ids.append(user_message['id'])
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id, grandparent_id, {'childrenIds': child_ids}
)
# Insert chat files from user message if any
user_message_files = user_message.get('files', [])
if user_message_files:
try:
await Chats.insert_chat_files(
chat_id,
user_message.get('id'),
[
file_item.get('id')
for file_item in user_message_files
if file_item.get('type') == 'file'
],
user.id,
)
except Exception as e:
log.debug(f'Error inserting chat files: {e}')
pass
# Save ALL assistant placeholders
user_message_id = metadata.get('user_message_id')
all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id]
# Link user message → all assistant messages (childrenIds)
if user_message_id and all_assistant_ids:
existing_user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id)
if existing_user_message:
child_ids = existing_user_message.get('childrenIds', [])
for assistant_id in all_assistant_ids:
if assistant_id not in child_ids:
child_ids.append(assistant_id)
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
user_message_id,
{'childrenIds': child_ids},
)
# Save each assistant placeholder
for target_model_id, assistant_message_id in message_ids.items():
if assistant_message_id:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': target_model_id,
'timestamp': int(time.time()),
},
)
request.state.metadata = metadata
form_data['metadata'] = metadata
except HTTPException:
raise
except Exception as e:
log.debug(f'Error processing chat metadata: {e}')
raise HTTPException(
@ -1761,24 +1832,26 @@ async def chat_completion(
detail=str(e),
)
async def process_chat(request, form_data, user, metadata, model):
async def process_chat(request, form_data, user, metadata, model, tasks=None):
try:
form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model)
response = await chat_completion_handler(request, form_data, user)
if metadata.get('chat_id') and metadata.get('message_id'):
# When the upstream provider returns an error (e.g. HTTP 400
# content-filter, quota exceeded), generate_chat_completion
# returns a JSONResponse instead of raising. Detect this and
# raise so the except-block below emits chat:message:error +
# chat:tasks:cancel, unblocking the frontend.
if isinstance(response, JSONResponse) and response.status_code >= 400:
try:
if not metadata['chat_id'].startswith('local:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'parentId': metadata.get('parent_message_id', None),
'model': model_id,
},
)
error_body = json.loads(response.body.decode('utf-8', 'replace'))
detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body
if isinstance(detail, dict):
detail = detail.get('message', detail.get('detail', str(detail)))
except Exception:
pass
detail = f'Provider returned HTTP {response.status_code}'
raise Exception(detail)
ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events)
@ -1787,11 +1860,12 @@ async def chat_completion(
log.info('Chat processing was cancelled')
try:
event_emitter = await get_event_emitter(metadata)
await asyncio.shield(
event_emitter(
{'type': 'chat:tasks:cancel'},
if event_emitter:
await asyncio.shield(
event_emitter(
{'type': 'chat:tasks:cancel'},
)
)
)
except Exception as e:
pass
finally:
@ -1806,21 +1880,22 @@ async def chat_completion(
metadata['chat_id'],
metadata['message_id'],
{
'parentId': metadata.get('parent_message_id', None),
'parentId': metadata.get('user_message_id', None),
'error': {'content': str(e)},
},
)
event_emitter = await get_event_emitter(metadata)
await event_emitter(
{
'type': 'chat:message:error',
'data': {'error': {'content': str(e)}},
}
)
await event_emitter(
{'type': 'chat:tasks:cancel'},
)
if event_emitter:
await event_emitter(
{
'type': 'chat:message:error',
'data': {'error': {'content': str(e)}},
}
)
await event_emitter(
{'type': 'chat:tasks:cancel'},
)
except Exception:
pass
@ -1856,20 +1931,72 @@ async def chat_completion(
except Exception as e:
log.debug(f'Error emitting chat:active: {e}')
if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'):
# Asynchronous Chat Processing
task_id, _ = await create_task(
request.app.state.redis,
process_chat(request, form_data, user, metadata, model),
id=metadata['chat_id'],
)
# Emit chat:active=true when task starts
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
return {'status': True, 'task_id': task_id}
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
task_ids = []
chat_id = metadata['chat_id']
for idx, (target_model_id, assistant_message_id) in enumerate(message_ids.items()):
if not assistant_message_id:
continue
# Per-model metadata: own message_id + model
per_model_metadata = {
**metadata,
'message_id': assistant_message_id,
}
# Per-model form_data: own model
model_form_data = {
**form_data,
'model': target_model_id,
'metadata': per_model_metadata,
}
# Resolve the model object for this specific model
resolved_model = request.app.state.MODELS.get(target_model_id, model)
# Only the first model runs title/tags generation;
# subsequent models only run follow-ups.
task_id, _ = await create_task(
request.app.state.redis,
process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v
for k, v in (tasks or {}).items()
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
),
id=chat_id,
)
task_ids.append(task_id)
# Emit chat:active=true
if task_ids:
event_emitter = await get_event_emitter(
{**metadata, 'message_id': list(message_ids.values())[0]},
update_db=False,
)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
return {
'status': True,
'task_ids': task_ids,
'chat_id': chat_id,
}
else:
return await process_chat(request, form_data, user, metadata, model)
# Legacy/direct: single model, synchronous
metadata['message_id'] = list(message_ids.values())[0]
return await process_chat(request, form_data, user, metadata, model, tasks)
# Alias for chat_completion (Legacy)
@ -1943,6 +2070,8 @@ async def generate_messages(
@app.post('/api/chat/completed')
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
"""Deprecated: outlet filters now run inline during chat completion.
Kept for backward compatibility with external integrations."""
try:
model_item = form_data.pop('model_item', {})
@ -2076,6 +2205,7 @@ async def get_app_config(request: Request):
'enable_api_keys': app.state.config.ENABLE_API_KEYS,
'enable_signup': app.state.config.ENABLE_SIGNUP,
'enable_login_form': app.state.config.ENABLE_LOGIN_FORM,
'enable_password_change_form': app.state.config.ENABLE_PASSWORD_CHANGE_FORM,
'enable_websocket': ENABLE_WEBSOCKET_SUPPORT,
'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK,
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
@ -2286,10 +2416,8 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
auth_type = tool_server_connection.get('auth_type', 'none')
if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'):
oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '')
try:
oauth_client_info = decrypt_data(oauth_client_info)
oauth_client_info = resolve_oauth_client_info(tool_server_connection)
app.state.oauth_client_manager.add_client(
f'mcp:{server_id}',
OAuthClientInformationFull(**oauth_client_info),
@ -2350,18 +2478,25 @@ async def register_client(request, client_id: str) -> bool:
try:
if auth_type == 'oauth_2.1_static':
# Static credentials: rebuild from stored credentials + fresh metadata
existing_client_info = connection.get('info', {}).get('oauth_client_info', '')
if not existing_client_info:
log.error(f'No stored OAuth client info for static client {client_id}')
return False
existing_data = decrypt_data(existing_client_info)
# Static credentials: rebuild from admin-provided credentials + fresh metadata
info = connection.get('info', {})
oauth_client_id = info.get('oauth_client_id') or ''
oauth_client_secret = info.get('oauth_client_secret') or ''
if not oauth_client_id or not oauth_client_secret:
# Fall back to blob for backward compatibility
existing_client_info = info.get('oauth_client_info', '')
if not existing_client_info:
log.error(f'No stored OAuth client info for static client {client_id}')
return False
existing_data = decrypt_data(existing_client_info)
oauth_client_id = oauth_client_id or existing_data.get('client_id', '')
oauth_client_secret = oauth_client_secret or existing_data.get('client_secret', '')
oauth_client_info = await get_oauth_client_info_with_static_credentials(
request,
client_id,
server_url,
oauth_client_id=existing_data.get('client_id', ''),
oauth_client_secret=existing_data.get('client_secret', ''),
oauth_client_id=oauth_client_id,
oauth_client_secret=oauth_client_secret,
)
else:
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
@ -2494,7 +2629,13 @@ async def oauth_backchannel_logout(
@app.get('/manifest.json')
async def get_manifest_json():
if app.state.EXTERNAL_PWA_MANIFEST_URL:
return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json()
session = await get_session()
async with session.get(
app.state.EXTERNAL_PWA_MANIFEST_URL,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.json()
else:
return {
'name': app.state.WEBUI_NAME,

View file

@ -0,0 +1,164 @@
"""Add shared_chat table and migrate existing shares
Revision ID: c1d2e3f4a5b6
Revises: e1f2a3b4c5d6
Create Date: 2026-04-16 23:00:00.000000
"""
import time
import uuid
from alembic import op
import sqlalchemy as sa
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
branch_labels = None
depends_on = None
# Lightweight table references for data migration (no ORM models needed)
chat_t = sa.table(
'chat',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('share_id', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
sa.column('archived', sa.Boolean),
sa.column('meta', sa.JSON),
)
shared_chat_t = sa.table(
'shared_chat',
sa.column('id', sa.Text),
sa.column('chat_id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
chat_message_t = sa.table(
'chat_message',
sa.column('chat_id', sa.Text),
)
access_grant_t = sa.table(
'access_grant',
sa.column('id', sa.Text),
sa.column('resource_type', sa.Text),
sa.column('resource_id', sa.Text),
sa.column('principal_type', sa.Text),
sa.column('principal_id', sa.Text),
sa.column('permission', sa.Text),
sa.column('created_at', sa.BigInteger),
)
def upgrade():
conn = op.get_bind()
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
sa.select(
chat_t.c.id,
chat_t.c.user_id,
chat_t.c.title,
chat_t.c.chat,
chat_t.c.created_at,
chat_t.c.updated_at,
).where(chat_t.c.user_id.like('shared-%'))
).fetchall()
for row in shared_rows:
share_token = row.id
original_chat_id = row.user_id.replace('shared-', '', 1)
# Verify original chat still exists
original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone()
if not original:
continue
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
# 3. Clean up old phantom rows
conn.execute(
chat_message_t.delete().where(
chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')))
)
)
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
def downgrade():
conn = op.get_bind()
shared_rows = conn.execute(
sa.select(
shared_chat_t.c.id,
shared_chat_t.c.chat_id,
shared_chat_t.c.user_id,
shared_chat_t.c.title,
shared_chat_t.c.chat,
shared_chat_t.c.created_at,
shared_chat_t.c.updated_at,
)
).fetchall()
for row in shared_rows:
conn.execute(
chat_t.insert().values(
id=row.id,
user_id=f'shared-{row.chat_id}',
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
archived=False,
meta={},
)
)
conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat'))
op.drop_table('shared_chat')

View file

@ -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')

View file

@ -293,10 +293,9 @@ class ChatTable:
return changed
async def insert_new_chat(
self, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None
self, id: str, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None
) -> Optional[ChatModel]:
async with get_async_db_context(db) as db:
id = str(uuid.uuid4())
chat = ChatModel(
**{
'id': id,
@ -556,77 +555,51 @@ class ChatTable:
async def insert_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[AsyncSession] = None
) -> Optional[ChatModel]:
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
from open_webui.models.shared_chats import SharedChats
async with get_async_db_context(db) as db:
# Get the existing chat to share
chat = await db.get(Chat, chat_id)
# Check if chat exists
if not chat:
return None
# Check if the chat is already shared
if chat.share_id:
return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db)
# Create a new chat with the same data, but with a new ID
shared_chat = ChatModel(
**{
'id': str(uuid.uuid4()),
'user_id': f'shared-{chat_id}',
'title': chat.title,
'chat': chat.chat,
'meta': chat.meta,
'pinned': chat.pinned,
'folder_id': chat.folder_id,
'created_at': chat.created_at,
'updated_at': int(time.time()),
}
)
shared_result = Chat(**shared_chat.model_dump())
db.add(shared_result)
await db.commit()
await db.refresh(shared_result)
# Update the original chat with the share_id
await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id))
# If already shared, just update the existing snapshot
if chat.share_id:
return await self.update_shared_chat_by_chat_id(chat_id, db=db)
shared = await SharedChats.create(chat_id, chat.user_id, db=db)
if not shared:
return None
# Set share_id on the original chat
chat.share_id = shared.id
await db.commit()
return shared_chat if shared_result else None
await db.refresh(chat)
return ChatModel.model_validate(chat)
async def update_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[AsyncSession] = None
) -> Optional[ChatModel]:
"""Re-snapshot the shared chat with current chat data."""
from open_webui.models.shared_chats import SharedChats
try:
async with get_async_db_context(db) as db:
chat = await db.get(Chat, chat_id)
result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}'))
shared_chat = result.scalars().first()
if shared_chat is None:
if not chat or not chat.share_id:
return await self.insert_shared_chat_by_chat_id(chat_id, db=db)
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.meta = chat.meta
shared_chat.pinned = chat.pinned
shared_chat.folder_id = chat.folder_id
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return ChatModel.model_validate(shared_chat)
await SharedChats.update(chat.share_id, db=db)
return ChatModel.model_validate(chat)
except Exception:
return None
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete shared snapshot for a chat."""
from open_webui.models.shared_chats import SharedChats
try:
async with get_async_db_context(db) as db:
# Get shared chat IDs
result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}'))
shared_ids = [row[0] for row in result.all()]
if shared_ids:
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}'))
await db.commit()
return True
return await SharedChats.delete_by_chat_id(chat_id, db=db)
except Exception:
return False
@ -747,53 +720,10 @@ class ChatTable:
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[SharedChatResponse]:
async with get_async_db_context(db) as db:
stmt = (
select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at)
.filter_by(user_id=user_id)
.filter(Chat.share_id.isnot(None))
)
"""Delegate to SharedChats for listing shared chats by user."""
from open_webui.models.shared_chats import SharedChats
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
if not getattr(Chat, order_by, None):
raise ValueError('Invalid order_by field')
if direction.lower() == 'asc':
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == 'desc':
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
all_chats = result.all()
return [
SharedChatResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'share_id': chat[2],
'updated_at': chat[3],
'created_at': chat[4],
}
)
for chat in all_chats
]
return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db)
async def get_chat_list_by_user_id(
self,
@ -926,15 +856,23 @@ class ChatTable:
return None
async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Chat).filter_by(share_id=id))
chat = result.scalars().first()
"""Look up a shared chat snapshot by its share token."""
from open_webui.models.shared_chats import SharedChats
if chat:
return await self.get_chat_by_id(id, db=db)
else:
return None
try:
shared = await SharedChats.get_by_id(id, db=db)
if shared:
# Return a ChatModel-compatible view of the snapshot
return ChatModel(
id=shared.id,
user_id=shared.user_id,
title=shared.title,
chat=shared.chat,
created_at=shared.created_at,
updated_at=shared.updated_at,
share_id=shared.id,
)
return None
except Exception:
return None
@ -1324,8 +1262,10 @@ class ChatTable:
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> list[TagModel]:
async with get_async_db_context(db) as db:
chat = await db.get(Chat, id)
tag_ids = chat.meta.get('tags', [])
stmt = select(Chat.meta).where(Chat.id == id)
result = await db.execute(stmt)
meta = result.scalar_one_or_none()
tag_ids = (meta or {}).get('tags', [])
return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
async def get_chat_list_by_user_id_and_tag_name(
@ -1569,20 +1509,17 @@ class ChatTable:
return False
async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chat snapshots created by a user."""
from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Chat.id).filter_by(user_id=user_id))
id_rows = result.all()
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
# Delete shared_chat rows for this user's chats
await db.execute(delete(SharedChatTable).filter_by(user_id=user_id))
if shared_chat_ids:
# Get shared chat IDs to delete associated messages
shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)))
shared_ids = [row[0] for row in shared_result.all()]
if shared_ids:
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids)))
await db.commit()
# Clear share_id on all of this user's chats
await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
await db.commit()
return True
except Exception:
@ -1599,11 +1536,11 @@ class ChatTable:
if not file_ids:
return None
chat_message_file_ids = [
chat_message_file_ids = {
item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db)
]
}
# Remove duplicates and existing file_ids
file_ids = list(set([file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids]))
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids})
if not file_ids:
return None
@ -1652,16 +1589,15 @@ class ChatTable:
except Exception:
return False
async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]:
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]:
"""Return IDs of chats that contain this file and have an active share link."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Chat)
select(Chat.id)
.join(ChatFile, Chat.id == ChatFile.chat_id)
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
)
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [row[0] for row in result.all()]
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
"""Update the tasks list on a chat."""

View file

@ -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:

View file

@ -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:

View file

@ -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,
)
)

View file

@ -3,7 +3,7 @@ import time
import uuid
from typing import Optional
from sqlalchemy import select, delete, update, or_, func, cast, String
from sqlalchemy import select, delete, update, or_, func, text, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.groups import Groups
@ -260,12 +260,12 @@ class PromptsTable:
) -> PromptListResponse:
async with get_async_db_context(db) as db:
# Join with User table for user filtering and sorting
stmt = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(
query = query.filter(
or_(
Prompt.name.ilike(f'%{query_key}%'),
Prompt.command.ilike(f'%{query_key}%'),
@ -277,14 +277,14 @@ class PromptsTable:
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Prompt.user_id == user_id)
query = query.filter(Prompt.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Prompt.user_id != user_id)
query = query.filter(Prompt.user_id != user_id)
# Apply access grant filtering
stmt = AccessGrants.has_permission_filter(
query = AccessGrants.has_permission_filter(
db=db,
query=stmt,
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type='prompt',
@ -293,56 +293,63 @@ class PromptsTable:
tag = filter.get('tag')
if tag:
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
# stores literal Unicode. Use the right pattern for each.
if db.bind.dialect.name == 'sqlite':
if tag.isascii():
tags_text = func.lower(cast(Prompt.tags, String))
pattern = f'%{json.dumps(tag.lower())}%'
else:
# LOWER() is ASCII-only; non-ASCII codepoints would
# produce different \uXXXX escapes when lowered.
tags_text = cast(Prompt.tags, String)
pattern = f'%{json.dumps(tag)}%'
bind = await db.connection()
dialect_name = bind.dialect.name
tag_lower = tag.lower()
if dialect_name == 'sqlite':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
)
elif dialect_name == 'postgresql':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
tags_text = func.lower(cast(Prompt.tags, String))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(tags_text.like(pattern))
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
tag_clause = func.lower(cast(Prompt.tags, String)).like(
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
)
tag_lower = None
if tag_lower is not None:
query = query.filter(tag_clause.params(tag_val=tag_lower))
else:
query = query.filter(tag_clause)
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Prompt.name.asc())
query = query.order_by(Prompt.name.asc())
else:
stmt = stmt.order_by(Prompt.name.desc())
query = query.order_by(Prompt.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Prompt.created_at.asc())
query = query.order_by(Prompt.created_at.asc())
else:
stmt = stmt.order_by(Prompt.created_at.desc())
query = query.order_by(Prompt.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Prompt.updated_at.asc())
query = query.order_by(Prompt.updated_at.asc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
query = query.offset(skip)
if limit:
stmt = stmt.limit(limit)
query = query.limit(limit)
result = await db.execute(stmt)
result = await db.execute(query)
items = result.all()
prompt_ids = [prompt.id for prompt, _ in items]

View file

@ -0,0 +1,207 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON
log = logging.getLogger(__name__)
####################
# SharedChat DB Schema
####################
class SharedChat(Base):
__tablename__ = 'shared_chat'
id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Text, nullable=False) # Who created this share
title = Column(Text)
chat = Column(JSON) # Snapshot of chat JSON at share time
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class SharedChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
title: str
chat: dict
created_at: int
updated_at: int
class SharedChatResponse(BaseModel):
id: str
chat_id: str
title: str
share_id: Optional[str] = None # Alias for id, for backward compat
updated_at: int
created_at: int
####################
# Table Operations
####################
class SharedChatsTable:
async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Create a snapshot of the chat for link sharing.
Returns the SharedChatModel with the share token as its id.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
chat = await db.get(Chat, chat_id)
if not chat:
return None
share_id = str(uuid.uuid4())
now = int(time.time())
shared_chat = SharedChat(
id=share_id,
chat_id=chat_id,
user_id=user_id,
title=chat.title,
chat=chat.chat,
created_at=now,
updated_at=now,
)
db.add(shared_chat)
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Re-snapshot: update the shared chat with the current state of the original chat.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
shared_chat = await db.get(SharedChat, share_id)
if not shared_chat:
return None
chat = await db.get(Chat, shared_chat.chat_id)
if not chat:
return None
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get a shared chat by its share token."""
async with get_async_db_context(db) as db:
shared_chat = await db.get(SharedChat, share_id)
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get the shared chat for a given original chat. Returns the most recent one."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1)
)
shared_chat = result.scalars().first()
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[SharedChatResponse]:
"""List all shared chats created by a user."""
async with get_async_db_context(db) as db:
stmt = select(SharedChat).filter_by(user_id=user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
col = getattr(SharedChat, order_by, None)
if not col:
raise ValueError('Invalid order_by field')
if direction.lower() == 'asc':
stmt = stmt.order_by(col.asc())
elif direction.lower() == 'desc':
stmt = stmt.order_by(col.desc())
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(SharedChat.updated_at.desc())
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return [
SharedChatResponse(
id=sc.chat_id,
chat_id=sc.chat_id,
title=sc.title,
share_id=sc.id,
updated_at=sc.updated_at,
created_at=sc.created_at,
)
for sc in result.scalars().all()
]
async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete a shared chat by its share token."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(id=share_id))
await db.commit()
return True
except Exception:
return False
async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chats for a given original chat."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(chat_id=chat_id))
await db.commit()
return True
except Exception:
return False
SharedChats = SharedChatsTable()

View file

@ -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

View file

@ -59,14 +59,14 @@ class ColBERT(BaseReranker):
return normalized_scores.detach().cpu().numpy().astype(np.float32)
def predict(self, sentences):
def predict(self, sentences, batch_size=32):
query = sentences[0][0]
docs = [i[1] for i in sentences]
# Embedding the documents
embedded_docs = self.ckpt.docFromText(docs, bsize=32)[0]
embedded_docs = self.ckpt.docFromText(docs, bsize=batch_size)[0]
# Embedding the queries
embedded_queries = self.ckpt.queryFromText([query], bsize=32)
embedded_queries = self.ckpt.queryFromText([query], bsize=batch_size)
embedded_query = embedded_queries[0]
# Calculate retrieval scores for the query against all documents

View file

@ -20,6 +20,7 @@ from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@ -121,7 +122,7 @@ class VectorSearchRetriever(BaseRetriever):
run_manager: CallbackManagerForRetrieverRun,
) -> list[Document]:
embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
result = VECTOR_DB_CLIENT.search(
result = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=self.collection_name,
vectors=[embedding],
limit=self.top_k,
@ -488,16 +489,24 @@ async def query_collection_with_hybrid_search(
) -> dict:
results = []
error = False
# Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later
collection_results = {}
for collection_name in collection_names:
# Fetch every collection's contents once up front so the
# per-query/per-document loop below can reuse them. Each fetch
# offloads to a worker thread, so run them concurrently with
# `asyncio.gather` instead of awaiting them serially — otherwise
# latency scales linearly with `len(collection_names)`.
log.debug(
'query_collection_with_hybrid_search: prefetching %d collections',
len(collection_names),
)
async def _fetch_collection(name: str):
try:
log.debug(f'query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}')
collection_results[collection_name] = VECTOR_DB_CLIENT.get(collection_name=collection_name)
return name, await ASYNC_VECTOR_DB_CLIENT.get(collection_name=name)
except Exception as e:
log.exception(f'Failed to fetch collection {collection_name}: {e}')
collection_results[collection_name] = None
log.exception(f'Failed to fetch collection {name}: {e}')
return name, None
collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names)))
log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...')
@ -910,7 +919,7 @@ async def generate_embeddings(
return embeddings[0] if isinstance(text, str) else embeddings
def get_reranking_function(reranking_engine, reranking_model, reranking_function):
def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32):
if reranking_function is None:
return None
if reranking_engine == 'external':
@ -919,10 +928,61 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function
)
else:
return lambda query, documents, user=None: reranking_function.predict(
[(query, doc.page_content) for doc in documents]
[(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size)
)
async def filter_accessible_collections(
collection_names: set[str],
user: UserModel,
access_type: str = 'read',
) -> set[str]:
"""
Return only the collection names the user is allowed to access.
Admins bypass all checks. For non-admins the policy is:
- file-* validated via has_access_to_file
- user-memory-* must match user's own memory collection
- web-search-* ephemeral per-query collections, always allowed
- knowledge-bases always denied (system meta-collection)
- everything else if the name matches a knowledge base, validated
via Knowledges.check_access_by_user_id; if no
such KB exists, the name is treated as an
ephemeral/legacy collection and allowed
"""
if user.role == 'admin':
return collection_names
validated = set()
for name in collection_names:
if name == 'knowledge-bases':
# System meta-collection — never exposed to non-admins.
continue
elif name.startswith('file-'):
file_id = name[len('file-') :]
if await has_access_to_file(file_id=file_id, access_type=access_type, user=user):
validated.add(name)
elif name.startswith('user-memory-'):
if name == f'user-memory-{user.id}':
validated.add(name)
elif name.startswith('web-search-'):
# Ephemeral collections created by process_web_search — safe
# to allow because they contain only transient web-search
# results scoped to the requesting user's session.
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
# exists, treat it as a non-sensitive collection (e.g. legacy
# model knowledge, process_text SHA256 collections) and allow.
if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type):
validated.add(name)
elif not await Knowledges.get_knowledge_by_id(name):
# Not a KB at all — legacy/ephemeral collection, allow
validated.add(name)
return validated
async def get_sources_from_items(
request,
items,
@ -1138,9 +1198,18 @@ async def get_sources_from_items(
log.debug(f'skipping {item} as it has already been extracted')
continue
# Filter out collections the user cannot read
if user:
collection_names = await filter_accessible_collections(collection_names, user)
if not collection_names:
log.debug(f'access denied for all collections in item {item}')
continue
try:
if full_context:
query_result = get_all_items_from_collections(collection_names)
# Sync helper makes blocking VECTOR_DB_CLIENT calls;
# offload so the async caller's event loop stays free.
query_result = await asyncio.to_thread(get_all_items_from_collections, collection_names)
else:
query_result = await query_collection(
request,

View file

@ -0,0 +1,129 @@
"""
Async facade over the synchronous VECTOR_DB_CLIENT.
The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant,
Milvus, OpenSearch, Pinecone, Weaviate, ) all expose a uniformly
synchronous API. Each method performs blocking network or disk I/O and
some, like `insert`/`upsert`, can run for several seconds.
When such a sync method is awaited from an async route handler, it blocks
the event loop for its entire duration, freezing every other in-flight
HTTP request, websocket message and background task.
This module wraps the sync client in an `AsyncVectorDBClient` that
transparently dispatches each call to a worker thread via
`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)`
in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive.
The original `VECTOR_DB_CLIENT` is unchanged, so callers already running
inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not
affected.
Thread-safety expectations
--------------------------
Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread
rather than the event-loop thread, and many can run concurrently. The
sync client (and its underlying backend driver) is therefore expected
to be safe for concurrent use across threads, which is the standard
contract for the bundled drivers (chroma, pgvector via SQLAlchemy
pool, qdrant-client, opensearch-py, ). This is *not* a new exposure
introduced by this facade `save_docs_to_vector_db` already called
the sync client from `run_in_threadpool`, so concurrent threaded
access has always been a requirement of the codebase. Adding a global
serialization lock here would defeat the responsiveness this facade
exists to provide; any backend that genuinely cannot tolerate
concurrent access should grow its own internal serialization.
API surface
-----------
Method signatures mirror `VectorDBBase` exactly. This is deliberate:
permissive `*args/**kwargs` forwarding hides typos at the call site
(an earlier revision of this file shipped that, and a `metadata=`
typo silently broke an entire endpoint until explicit signatures
surfaced it). Callers that need a backend-specific parameter not on
`VectorDBBase` should reach for the `.sync` escape hatch and wrap
their own `asyncio.to_thread`, e.g. ::
await asyncio.to_thread(
ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op,
collection_name, special_kwarg=value,
)
"""
from __future__ import annotations
import asyncio
from typing import Dict, List, Optional, Union
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
class AsyncVectorDBClient:
"""Awaitable mirror of `VectorDBBase` that off-loads each call to a thread.
Method signatures mirror `VectorDBBase` exactly so static analysis
catches bad kwargs at the call site instead of letting them surface
deep inside the worker thread (where the resulting ``TypeError`` is
typically swallowed by surrounding ``try/except``).
"""
def __init__(self, sync_client: VectorDBBase) -> None:
self._sync = sync_client
@property
def sync(self) -> VectorDBBase:
"""Escape hatch for code that must call the sync client directly
(e.g. already inside a worker thread)."""
return self._sync
async def has_collection(self, collection_name: str) -> bool:
return await asyncio.to_thread(self._sync.has_collection, collection_name)
async def delete_collection(self, collection_name: str) -> None:
return await asyncio.to_thread(self._sync.delete_collection, collection_name)
async def insert(self, collection_name: str, items: List[VectorItem]) -> None:
return await asyncio.to_thread(self._sync.insert, collection_name, items)
async def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
return await asyncio.to_thread(self._sync.upsert, collection_name, items)
async def search(
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit)
async def query(
self,
collection_name: str,
filter: Dict,
limit: Optional[int] = None,
) -> Optional[GetResult]:
return await asyncio.to_thread(self._sync.query, collection_name, filter, limit)
async def get(self, collection_name: str) -> Optional[GetResult]:
return await asyncio.to_thread(self._sync.get, collection_name)
async def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter)
async def reset(self) -> None:
return await asyncio.to_thread(self._sync.reset)
ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT)

View file

@ -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,
},

View file

@ -1,5 +1,7 @@
from datetime import datetime
from open_webui.utils.misc import sanitize_text_for_db
KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures']
@ -12,7 +14,8 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
# Removes large fields and converts non-serializable types (datetime, list, dict) to strings.
# Removes large fields, converts non-serializable types (datetime, list, dict) to strings,
# and sanitizes strings for database storage (strips null bytes and invalid surrogates).
result = {}
for key, value in metadata.items():
# Skip large fields
@ -20,7 +23,7 @@ def process_metadata(
continue
# Convert non-serializable fields to strings
if isinstance(value, (datetime, list, dict)):
result[key] = str(value)
result[key] = sanitize_text_for_db(str(value))
else:
result[key] = value
result[key] = sanitize_text_for_db(value)
return result

View file

@ -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 []

View file

@ -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}')

View file

@ -5,7 +5,6 @@ import os
import uuid
import html
import base64
from functools import lru_cache
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
@ -54,6 +53,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_PYDUB_PREPROCESSING,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
@ -420,7 +420,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
voice_id = payload.get('voice', '')
if voice_id not in get_available_voices(request):
if voice_id not in await get_available_voices(request):
raise HTTPException(
status_code=400,
detail='Invalid voice id',
@ -974,7 +974,10 @@ def transcription_handler(request, file_path, metadata, user=None):
# Read and encode audio file as base64
with open(audio_file_to_use, 'rb') as audio_file:
audio_base64 = base64.b64encode(audio_file.read()).decode('utf-8')
audio_base64 = {
'data': base64.b64encode(audio_file.read()).decode('utf-8'),
'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
}
# Prepare chat completions request
url = f'{api_base_url}/chat/completions'
@ -1098,24 +1101,28 @@ def transcription_handler(request, file_path, metadata, user=None):
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
log.info(f'transcribe: {file_path} {metadata}')
if is_audio_conversion_required(file_path):
file_path = convert_audio_to_mp3(file_path)
if BYPASS_PYDUB_PREPROCESSING:
log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)')
chunk_paths = [file_path]
else:
if is_audio_conversion_required(file_path):
file_path = convert_audio_to_mp3(file_path)
try:
file_path = compress_audio(file_path)
except Exception as e:
log.exception(e)
try:
file_path = compress_audio(file_path)
except Exception as e:
log.exception(e)
# Always produce a list of chunk paths (could be one entry if small)
try:
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
print(f'Chunk paths: {chunk_paths}')
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
# Always produce a list of chunk paths (could be one entry if small)
try:
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
print(f'Chunk paths: {chunk_paths}')
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
results = []
try:
@ -1287,40 +1294,53 @@ async def transcription(
)
def get_available_models(request: Request) -> list[dict]:
async def get_available_models(request: Request) -> list[dict]:
available_models = []
if request.app.state.config.TTS_ENGINE == 'openai':
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'):
try:
response = requests.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models',
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
available_models = data.get('models', [])
except Exception as e:
log.error(f'Error fetching models from custom endpoint: {str(e)}')
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
try:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models',
) as response:
response.raise_for_status()
data = await response.json()
available_models = data.get('models', [])
except Exception as e:
log.debug(f'/audio/models not available, trying /models fallback: {str(e)}')
# Fallback to standard OpenAI-compatible /models endpoint
# (used by KokoroTTS and similar custom TTS servers)
try:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/models',
) as response:
response.raise_for_status()
data = await response.json()
# OpenAI /models returns {"data": [...]}, /audio/models returns {"models": [...]}
available_models = data.get('data', data.get('models', []))
except Exception as e2:
log.error(f'Error fetching models from custom endpoint: {str(e2)}')
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
else:
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
try:
response = requests.get(
f'{ELEVENLABS_API_BASE_URL}/v1/models',
headers={
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
timeout=5,
)
response.raise_for_status()
models = response.json()
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
except requests.RequestException as e:
log.error(f'Error fetching voices: {str(e)}')
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/models',
headers={
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
) as response:
response.raise_for_status()
models = await response.json()
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
except Exception as e:
log.error(f'Error fetching models: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
available_models = [{'id': 'mistral-tts-latest'}]
return available_models
@ -1328,24 +1348,25 @@ def get_available_models(request: Request) -> list[dict]:
@router.get('/models')
async def get_models(request: Request, user=Depends(get_verified_user)):
return {'models': get_available_models(request)}
return {'models': await get_available_models(request)}
def get_available_voices(request) -> dict:
async def get_available_voices(request) -> dict:
"""Returns {voice_id: voice_name} dict"""
available_voices = {}
if request.app.state.config.TTS_ENGINE == 'openai':
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'):
try:
response = requests.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices',
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
voices_list = data.get('voices', [])
available_voices = {voice['id']: voice['name'] for voice in voices_list}
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices',
) as response:
response.raise_for_status()
data = await response.json()
voices_list = data.get('voices', [])
available_voices = {voice['id']: voice['name'] for voice in voices_list}
except Exception as e:
log.error(f'Error fetching voices from custom endpoint: {str(e)}')
available_voices = {
@ -1367,7 +1388,7 @@ def get_available_voices(request) -> dict:
}
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
try:
available_voices = get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY)
available_voices = await get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY)
except Exception:
# Avoided @lru_cache with exception
pass
@ -1378,13 +1399,15 @@ def get_available_voices(request) -> dict:
url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list'
headers = {'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY}
response = requests.get(url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
response.raise_for_status()
voices = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(url, headers=headers) as response:
response.raise_for_status()
voices = await response.json()
for voice in voices:
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
except requests.RequestException as e:
for voice in voices:
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
except Exception as e:
log.error(f'Error fetching voices: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
@ -1392,29 +1415,29 @@ def get_available_voices(request) -> dict:
if api_key:
try:
response = requests.get(
f'{api_base_url}/audio/voices',
headers={
'Authorization': f'Bearer {api_key}',
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{api_base_url}/audio/voices',
headers={
'Authorization': f'Bearer {api_key}',
},
) as response:
response.raise_for_status()
voices_data = await response.json()
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except requests.RequestException as e:
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except Exception as e:
log.error(f'Error fetching Mistral voices: {str(e)}')
return available_voices
@lru_cache
def get_elevenlabs_voices(api_key: str) -> dict:
async def get_elevenlabs_voices(api_key: str) -> dict:
"""
Note, set the following in your .env file to use Elevenlabs:
AUDIO_TTS_ENGINE=elevenlabs
@ -1425,22 +1448,22 @@ def get_elevenlabs_voices(api_key: str) -> dict:
try:
# TODO: Add retries
response = requests.get(
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
headers={
'xi-api-key': api_key,
'Content-Type': 'application/json',
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
headers={
'xi-api-key': api_key,
'Content-Type': 'application/json',
},
) as response:
response.raise_for_status()
voices_data = await response.json()
voices = {}
for voice in voices_data.get('voices', []):
voices[voice['voice_id']] = voice['name']
except requests.RequestException as e:
# Avoid @lru_cache with exception
voices = {}
for voice in voices_data.get('voices', []):
voices[voice['voice_id']] = voice['name']
except Exception as e:
log.error(f'Error fetching voices: {str(e)}')
raise RuntimeError(f'Error fetching voices: {str(e)}')
@ -1449,4 +1472,4 @@ def get_elevenlabs_voices(api_key: str) -> dict:
@router.get('/voices')
async def get_voices(request: Request, user=Depends(get_verified_user)):
return {'voices': [{'id': k, 'name': v} for k, v in get_available_voices(request).items()]}
return {'voices': [{'id': k, 'name': v} for k, v in (await get_available_voices(request)).items()]}

View file

@ -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

View file

@ -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),
)

View file

@ -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

View file

@ -1,6 +1,7 @@
import json
import logging
from typing import Optional
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from fastapi.responses import StreamingResponse
@ -16,13 +17,14 @@ from open_webui.models.chats import (
ChatResponse,
Chats,
ChatTitleIdResponse,
SharedChatResponse,
ChatStatsExport,
AggregateChatStats,
ChatBody,
ChatHistoryStats,
MessageStats,
)
from open_webui.models.shared_chats import SharedChats, SharedChatResponse
from open_webui.models.access_grants import AccessGrants
from open_webui.models.tags import TagModel, Tags
from open_webui.models.folders import Folders
from open_webui.internal.db import get_async_session
@ -34,7 +36,7 @@ from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
log = logging.getLogger(__name__)
@ -557,7 +559,7 @@ async def create_new_chat(
db: AsyncSession = Depends(get_async_session),
):
try:
chat = await Chats.insert_new_chat(user.id, form_data, db=db)
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
return ChatResponse(**chat.model_dump())
except Exception as e:
log.exception(e)
@ -806,7 +808,7 @@ async def get_shared_session_user_chat_list(
if direction:
filter['direction'] = direction
return await Chats.get_shared_chat_list_by_user_id(
return await SharedChats.get_by_user_id(
user.id,
filter=filter,
skip=skip,
@ -827,17 +829,32 @@ async def get_shared_chat_by_id(
if user.role == 'pending':
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS):
chat = await Chats.get_chat_by_share_id(share_id, db=db)
elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(share_id, db=db)
if chat:
return ChatResponse(**chat.model_dump())
else:
chat = await Chats.get_chat_by_share_id(share_id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
# Look up the original chat_id to check access grants
shared = await SharedChats.get_by_id(share_id, db=db)
if shared:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return ChatResponse(**chat.model_dump())
############################
# GetChatsByTags
@ -877,11 +894,25 @@ async def get_user_chat_list_by_tag_name(
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
if chat:
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
############################
@ -1157,39 +1188,58 @@ async def clone_shared_chat_by_id(
else:
chat = await Chats.get_chat_by_share_id(id, db=db)
if chat:
updated_chat = {
**chat.chat,
'originalChatId': chat.id,
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
}
chats = await Chats.import_chats(
user.id,
[
ChatImportForm(
**{
'chat': updated_chat,
'meta': chat.meta,
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
)
],
db=db,
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
else:
# Enforce access grants
shared = await SharedChats.get_by_id(id, db=db)
if shared and user.role != 'admin':
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
updated_chat = {
**chat.chat,
'originalChatId': chat.id,
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
}
chats = await Chats.import_chats(
user.id,
[
ChatImportForm(
**{
'chat': updated_chat,
'meta': chat.meta,
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
)
],
db=db,
)
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
@ -1240,16 +1290,28 @@ async def share_chat_by_id(
if chat:
if chat.share_id:
shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db)
return ChatResponse(**shared_chat.model_dump())
# Re-snapshot existing share
shared = await SharedChats.update(chat.share_id, db=db)
if shared:
# Re-fetch the original chat to return
chat = await Chats.get_chat_by_id(id, db=db)
return ChatResponse(**chat.model_dump())
shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db)
if not shared_chat:
# Create new share
shared = await SharedChats.create(id, user.id, db=db)
if not shared:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
return ChatResponse(**shared_chat.model_dump())
# Set share_id on the original chat
chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
@ -1259,7 +1321,7 @@ async def share_chat_by_id(
############################
# DeletedSharedChatById
# DeleteSharedChatById
############################
@ -1272,10 +1334,13 @@ async def delete_shared_chat_by_id(
if not chat.share_id:
return False
result = await Chats.delete_shared_chat_by_chat_id(id, db=db)
update_result = await Chats.update_chat_share_id_by_id(id, None, db=db)
await SharedChats.delete_by_chat_id(id, db=db)
await Chats.update_chat_share_id_by_id(id, None, db=db)
return result and update_result != None
# Revoke all access grants for this shared chat
await AccessGrants.set_access_grants('shared_chat', id, [], db=db)
return True
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -1283,6 +1348,85 @@ async def delete_shared_chat_by_id(
)
############################
# UpdateSharedChatAccessById
############################
class ChatAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse])
async def update_shared_chat_access_by_id(
request: Request,
id: str,
form_data: ChatAccessGrantsForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_chats',
)
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
return ChatResponse(**chat.model_dump())
############################
# GetSharedChatAccessById
############################
@router.get('/shared/{id}/access', response_model=list)
async def get_shared_chat_access_by_id(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
return [
{
'id': g.id,
'principal_type': g.principal_type,
'principal_id': g.principal_id,
'permission': g.permission,
}
for g in grants
]
############################
# UpdateChatFolderIdById
############################

View file

@ -8,7 +8,7 @@ from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import get_config, save_config, async_save_config
from open_webui.config import BannerModel
from open_webui.utils.tools import (
@ -27,6 +27,7 @@ from open_webui.utils.oauth import (
get_oauth_client_info_with_static_credentials,
encrypt_data,
decrypt_data,
resolve_oauth_client_info,
OAuthClientInformationFull,
)
from mcp.shared.auth import OAuthMetadata
@ -49,7 +50,7 @@ class ImportConfigForm(BaseModel):
@router.post('/import', response_model=dict)
async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)):
save_config(form_data.config)
await async_save_config(form_data.config)
return get_config()
@ -203,9 +204,7 @@ async def set_tool_servers_config(
if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id:
try:
oauth_client_info = connection.get('info', {}).get('oauth_client_info', '')
oauth_client_info = decrypt_data(oauth_client_info)
oauth_client_info = resolve_oauth_client_info(connection)
request.app.state.oauth_client_manager.add_client(
f'{server_type}:{server_id}',
OAuthClientInformationFull(**oauth_client_info),

View file

@ -25,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import get_async_session, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.models.channels import Channels
from open_webui.models.users import Users
@ -124,7 +124,7 @@ async def process_uploaded_file(
stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
if strict_match_mime_type(stt_supported_content_types, content_type):
file_path_processed = Storage.get_file(file_path)
file_path_processed = await asyncio.to_thread(Storage.get_file, file_path)
result = transcribe(request, file_path_processed, file_metadata, user)
await process_file(
@ -242,7 +242,8 @@ async def upload_file_handler(
id = str(uuid.uuid4())
name = filename
filename = f'{id}_{filename}'
contents, file_path = Storage.upload_file(
contents, file_path = await asyncio.to_thread(
Storage.upload_file,
file.file,
filename,
{
@ -406,8 +407,8 @@ async def delete_all_files(user=Depends(get_admin_user), db: AsyncSession = Depe
result = await Files.delete_all_files(db=db)
if result:
try:
Storage.delete_all_files()
VECTOR_DB_CLIENT.reset()
await asyncio.to_thread(Storage.delete_all_files)
await ASYNC_VECTOR_DB_CLIENT.reset()
except Exception as e:
log.exception(e)
log.error('Error deleting files')
@ -577,7 +578,7 @@ async def update_file_data_content_by_id(
for knowledge in knowledges:
try:
# Remove old embeddings for this file from the KB collection
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
# Re-add from the now-updated file-{file_id} collection
await process_file(
request,
@ -618,7 +619,7 @@ async def get_file_content_by_id(
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
try:
file_path = Storage.get_file(file.path)
file_path = await asyncio.to_thread(Storage.get_file, file.path)
file_path = Path(file_path)
# Check if the file already exists in the cache
@ -685,7 +686,7 @@ async def get_html_file_content_by_id(
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
try:
file_path = Storage.get_file(file.path)
file_path = await asyncio.to_thread(Storage.get_file, file.path)
file_path = Path(file_path)
# Check if the file already exists in the cache
@ -734,7 +735,7 @@ async def get_file_content_by_id(
headers = {'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"}
if file_path:
file_path = Storage.get_file(file_path)
file_path = await asyncio.to_thread(Storage.get_file, file_path)
file_path = Path(file_path)
# Check if the file already exists in the cache
@ -789,17 +790,17 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncS
await Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
try:
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
if file.hash:
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash})
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash})
except Exception as e:
log.debug(f'KB embedding cleanup for {knowledge.id}: {e}')
result = await Files.delete_file_by_id(id, db=db)
if result:
try:
Storage.delete_file(file.path)
VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}')
await asyncio.to_thread(Storage.delete_file, file.path)
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}')
except Exception as e:
log.exception(e)
log.error('Error deleting files')

View file

@ -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))
############################

View file

@ -10,7 +10,8 @@ from pathlib import Path
from typing import Optional
from urllib.parse import quote
import requests
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
@ -21,7 +22,8 @@ from open_webui.config import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.web.utils import validate_url
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.utils.session_pool import get_session
from open_webui.models.chats import Chats
from open_webui.routers.files import upload_file_handler, get_file_content_by_id
@ -50,32 +52,36 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter()
def set_image_model(request: Request, model: str):
async def set_image_model(request: Request, model: str):
log.info(f'Setting image model to {model}')
request.app.state.config.IMAGE_GENERATION_MODEL = model
if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
api_auth = get_automatic1111_api_auth(request)
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': api_auth},
)
options = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
options = await r.json()
if model != options['sd_model_checkpoint']:
options['sd_model_checkpoint'] = model
r = requests.post(
async with session.post(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
json=options,
headers={'authorization': api_auth},
)
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
except Exception as e:
log.debug(f'{e}')
return request.app.state.config.IMAGE_GENERATION_MODEL
def get_image_model(request):
async def get_image_model(request):
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
return (
request.app.state.config.IMAGE_GENERATION_MODEL
@ -97,14 +103,15 @@ def get_image_model(request):
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(request)},
)
options = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
options = await r.json()
return options['sd_model_checkpoint']
except Exception as e:
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
@ -198,7 +205,7 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends(
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if form_data.IMAGE_SIZE == 'auto' and not re.match(
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
):
@ -313,28 +320,30 @@ def get_automatic1111_api_auth(request: Request):
async def verify_url(request: Request, user=Depends(get_admin_user)):
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111':
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(request)},
)
r.raise_for_status()
return True
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return True
except Exception:
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
headers=headers,
)
r.raise_for_status()
return True
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return True
except Exception:
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
else:
return True
@ -357,11 +366,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
# TODO - get models from comfyui
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
headers=headers,
)
info = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
info = await r.json()
workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW)
model_node_id = None
@ -399,11 +410,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
headers={'authorization': get_automatic1111_api_auth(request)},
)
models = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
models = await r.json()
return list(
map(
lambda model: {'id': model['title'], 'name': model['model_name']},
@ -411,7 +424,6 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
)
)
except Exception as e:
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
@ -427,21 +439,22 @@ class CreateImageForm(BaseModel):
GenerateImageForm = CreateImageForm # Alias for backward compatibility
def get_image_data(data: str, headers=None):
async def get_image_data(data: str, headers=None):
try:
if data.startswith('http://') or data.startswith('https://'):
if headers:
r = requests.get(data, headers=headers)
else:
r = requests.get(data)
r.raise_for_status()
if r.headers['content-type'].split('/')[0] == 'image':
mime_type = r.headers['content-type']
return r.content, mime_type
else:
log.error('Url does not point to an image.')
return None
session = await get_session()
async with session.get(
data,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
content_type = r.headers.get('content-type', '')
if content_type.split('/')[0] == 'image':
return await r.read(), content_type
else:
log.error('Url does not point to an image.')
return None, None
else:
if ',' in data:
header, encoded = data.split(',', 1)
@ -531,9 +544,8 @@ async def image_generations(
metadata = metadata or {}
model = get_image_model(request)
model = await get_image_model(request)
r = None
try:
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
headers = {
@ -552,7 +564,11 @@ async def image_generations(
'model': model,
'prompt': form_data.prompt,
'n': form_data.n,
'size': (form_data.size if form_data.size else request.app.state.config.IMAGE_SIZE),
**(
{'size': form_data.size or request.app.state.config.IMAGE_SIZE}
if (form_data.size or request.app.state.config.IMAGE_SIZE)
else {}
),
**(
{}
if re.match(
@ -568,27 +584,26 @@ async def image_generations(
),
}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
session = await get_session()
async with session.post(
url=url,
json=data,
headers=headers,
)
r.raise_for_status()
res = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
res = await r.json()
images = []
for image in res['data']:
if image_url := image.get('url', None):
image_data, content_type = get_image_data(
image_data, content_type = await get_image_data(
image_url,
{k: v for k, v in headers.items() if k != 'Content-Type'},
)
else:
image_data, content_type = get_image_data(image['b64_json'])
image_data, content_type = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
@ -619,29 +634,28 @@ async def image_generations(
model = f'{model}:generateContent'
data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
session = await get_session()
async with session.post(
url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
json=data,
headers=headers,
)
r.raise_for_status()
res = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
res = await r.json()
images = []
if model.endswith(':predict'):
for image in res['predictions']:
image_data, content_type = get_image_data(image['bytesBase64Encoded'])
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
elif model.endswith(':generateContent'):
for image in res['candidates']:
for part in image['content']['parts']:
if part.get('inlineData', {}).get('data'):
image_data, content_type = get_image_data(part['inlineData']['data'])
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, url = await upload_image(
request,
image_data,
@ -694,7 +708,7 @@ async def image_generations(
if request.app.state.config.COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
image_data, content_type = get_image_data(image['url'], headers)
image_data, content_type = await get_image_data(image['url'], headers)
_, url = await upload_image(
request,
image_data,
@ -709,7 +723,7 @@ async def image_generations(
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
if form_data.model:
set_image_model(request, form_data.model)
await set_image_model(request, form_data.model)
data = {
'prompt': form_data.prompt,
@ -727,21 +741,20 @@ async def image_generations(
if request.app.state.config.AUTOMATIC1111_PARAMS:
data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
session = await get_session()
async with session.post(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
json=data,
headers={'authorization': get_automatic1111_api_auth(request)},
)
res = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
res = await r.json()
log.debug(f'res: {res}')
images = []
for image in res['images']:
image_data, content_type = get_image_data(image)
image_data, content_type = await get_image_data(image)
_, url = await upload_image(
request,
image_data,
@ -753,10 +766,8 @@ async def image_generations(
return images
except Exception as e:
error = e
if r != None:
data = r.json()
if 'error' in data:
error = data['error']['message']
if isinstance(e, aiohttp.ClientResponseError):
error = e.message
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
@ -798,11 +809,12 @@ async def image_edits(
if data.startswith('http://') or data.startswith('https://'):
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(data)
r = await asyncio.to_thread(requests.get, data)
r.raise_for_status()
session = await get_session()
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
r.raise_for_status()
image_data = base64.b64encode(r.content).decode('utf-8')
return f'data:{r.headers["content-type"]};base64,{image_data}'
image_data = base64.b64encode(await r.read()).decode('utf-8')
return f'data:{r.headers["content-type"]};base64,{image_data}'
else:
file_id = None
@ -846,7 +858,6 @@ async def image_edits(
),
)
r = None
try:
if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai':
headers = {
@ -883,27 +894,40 @@ async def image_edits(
if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION:
url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}'
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
# Build multipart form data for aiohttp
form = aiohttp.FormData()
for key, value in data.items():
if isinstance(value, dict):
form.add_field(key, json.dumps(value))
else:
form.add_field(key, str(value))
for param_name, (filename, file_obj, content_type_val) in files:
form.add_field(
param_name,
file_obj,
filename=filename,
content_type=content_type_val,
)
session = await get_session()
async with session.post(
url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
headers=headers,
files=files,
data=data,
)
r.raise_for_status()
res = r.json()
data=form,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
res = await r.json()
images = []
for image in res['data']:
if image_url := image.get('url', None):
image_data, content_type = get_image_data(
image_data, content_type = await get_image_data(
image_url,
{k: v for k, v in headers.items() if k != 'Content-Type'},
)
else:
image_data, content_type = get_image_data(image['b64_json'])
image_data, content_type = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
@ -940,22 +964,21 @@ async def image_edits(
]
)
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
session = await get_session()
async with session.post(
url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
json=data,
headers=headers,
)
r.raise_for_status()
res = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
res = await r.json()
images = []
for image in res['candidates']:
for part in image['content']['parts']:
if part.get('inlineData', {}).get('data'):
image_data, content_type = get_image_data(part['inlineData']['data'])
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, url = await upload_image(
request,
image_data,
@ -1035,7 +1058,7 @@ async def image_edits(
if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY:
headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'}
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = await get_image_data(image_url, headers)
_, url = await upload_image(
request,
image_data,
@ -1048,13 +1071,7 @@ async def image_edits(
return images
except Exception as e:
error = e
if r != None:
data = r.text
try:
data = json.loads(data)
if 'error' in data:
error = data['error']['message']
except Exception:
error = data
if isinstance(e, aiohttp.ClientResponseError):
error = e.message
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))

View file

@ -19,7 +19,7 @@ from open_webui.models.knowledge import (
KnowledgeUserResponse,
)
from open_webui.models.files import Files, FileModel, FileMetadataResponse
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.routers.retrieval import (
process_file,
ProcessFileForm,
@ -66,7 +66,7 @@ async def embed_knowledge_base_metadata(
try:
content = f'{name}\n\n{description}' if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
VECTOR_DB_CLIENT.upsert(
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
{
@ -85,10 +85,10 @@ async def embed_knowledge_base_metadata(
return False
def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
"""Remove knowledge base embedding."""
try:
VECTOR_DB_CLIENT.delete(
await ASYNC_VECTOR_DB_CLIENT.delete(
collection_name=KNOWLEDGE_BASES_COLLECTION,
ids=[knowledge_base_id],
)
@ -310,8 +310,8 @@ async def reindex_knowledge_files(
try:
files = await Knowledges.get_files_by_id(knowledge_base.id, db=db)
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id)
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id)
except Exception as e:
log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}')
continue # Skip, don't raise
@ -539,10 +539,12 @@ async def update_knowledge_access_by_id(
'sharing.public_knowledge',
)
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
knowledge.access_grants = await AccessGrants.set_access_grants(
'knowledge', id, form_data.access_grants, db=db
)
return KnowledgeFilesResponse(
**(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(),
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
)
@ -732,7 +734,7 @@ async def update_file_from_knowledge_by_id(
)
# Remove content from the vector database
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id})
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id})
# Add content to the vector database
try:
@ -814,11 +816,11 @@ async def remove_file_from_knowledge_by_id(
# Remove content from the vector database
try:
VECTOR_DB_CLIENT.delete(
await ASYNC_VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={'file_id': form_data.file_id}
) # Remove by file_id first
VECTOR_DB_CLIENT.delete(
await ASYNC_VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={'hash': file.hash}
) # Remove by hash as well in case of duplicates
except Exception as e:
@ -826,12 +828,16 @@ async def remove_file_from_knowledge_by_id(
log.debug(e)
pass
if delete_file:
# Only the file owner or an admin may permanently delete the underlying
# file. Collaborators with KB write access can unlink a file from the
# knowledge base but must not be able to destroy files they do not own,
# as the same file may be referenced by other KBs and chats.
if delete_file and (file.user_id == user.id or user.role == 'admin'):
try:
# Remove the file's collection from vector database
file_collection = f'file-{form_data.file_id}'
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug('This was most likely caused by bypassing embedding processing')
log.debug(e)
@ -901,27 +907,18 @@ async def delete_knowledge_by_id(
if len(updated_knowledge) != len(knowledge_list):
log.info(f'Updating model {model.id} to remove knowledge base {id}')
model.meta.knowledge = updated_knowledge
# Create a ModelForm for the update
model_form = ModelForm(
id=model.id,
name=model.name,
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_grants=model.access_grants,
is_active=model.is_active,
)
model_form = ModelForm(**model.model_dump())
await Models.update_model_by_id(model.id, model_form, db=db)
# Clean up vector DB
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
log.debug(e)
pass
# Remove knowledge base embedding
remove_knowledge_base_metadata_embedding(id)
await remove_knowledge_base_metadata_embedding(id)
result = await Knowledges.delete_knowledge_by_id(id=id, db=db)
return result
@ -960,7 +957,7 @@ async def reset_knowledge_by_id(
)
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
log.debug(e)
pass

View file

@ -5,7 +5,7 @@ import asyncio
from typing import Optional
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.utils.auth import get_verified_user
from open_webui.internal.db import get_async_session
from sqlalchemy.ext.asyncio import AsyncSession
@ -85,7 +85,7 @@ async def add_memory(
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
VECTOR_DB_CLIENT.upsert(
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
{
@ -138,12 +138,44 @@ async def query_memory(
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)
results = VECTOR_DB_CLIENT.search(
results = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=f'user-memory-{user.id}',
vectors=[vector],
limit=form_data.k,
)
# Filter results by relevance threshold to avoid returning unrelated
# memories. Vector similarity search always returns the top-K nearest
# neighbours even when they are completely irrelevant; applying the
# same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching
# memories are surfaced (distances are normalised to 0→1, higher is
# better).
relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0)
if results and relevance_threshold > 0.0 and results.distances and results.distances[0]:
from open_webui.retrieval.vector.main import SearchResult
filtered_ids = []
filtered_docs = []
filtered_metas = []
filtered_dists = []
for idx, score in enumerate(results.distances[0]):
if score >= relevance_threshold:
if results.ids and results.ids[0]:
filtered_ids.append(results.ids[0][idx])
if results.documents and results.documents[0]:
filtered_docs.append(results.documents[0][idx])
if results.metadatas and results.metadatas[0]:
filtered_metas.append(results.metadatas[0][idx])
filtered_dists.append(score)
results = SearchResult(
ids=[filtered_ids] if filtered_ids else [[]],
documents=[filtered_docs] if filtered_docs else [[]],
metadatas=[filtered_metas] if filtered_metas else [[]],
distances=[filtered_dists] if filtered_dists else [[]],
)
return results
@ -175,7 +207,7 @@ async def reset_memory_from_vector_db(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
memories = await Memories.get_memories_by_user_id(user.id)
@ -184,7 +216,7 @@ async def reset_memory_from_vector_db(
*[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories]
)
VECTOR_DB_CLIENT.upsert(
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
{
@ -230,7 +262,7 @@ async def delete_memory_by_user_id(
if result:
try:
VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
except Exception as e:
log.error(e)
return True
@ -268,12 +300,12 @@ async def update_memory_by_id(
memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content)
if memory is None:
raise HTTPException(status_code=404, detail='Memory not found')
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
if form_data.content is not None:
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
VECTOR_DB_CLIENT.upsert(
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
{
@ -318,7 +350,7 @@ async def delete_memory_by_id(
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
if result:
VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
return True
return False

View file

@ -281,24 +281,71 @@ async def import_models(
model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else [])
}
# Batch-resolve write permissions in one query instead of
# per-model has_access calls (N+1 avoidance).
existing_model_ids = list(existing_models.keys())
if user.role != 'admin' and existing_model_ids:
groups = await Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
writable_model_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',
resource_ids=existing_model_ids,
permission='write',
user_group_ids=user_group_ids,
db=db,
)
else:
writable_model_ids = set(existing_model_ids)
for model_data in data:
# Here, you can add logic to validate model_data if needed
model_id = model_data.get('id')
if model_id and is_valid_model_id(model_id):
existing_model = existing_models.get(model_id)
if existing_model:
# Enforce ownership/write-access before allowing overwrite
if (
user.role != 'admin'
and existing_model.user_id != user.id
and model_id not in writable_model_ids
):
log.warning(
'import_models: user %s skipped model %s (no write access)',
user.id,
model_id,
)
continue
# Update existing model
model_data['meta'] = model_data.get('meta', {})
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
# Only filter access_grants when explicitly provided
# in the payload to avoid altering existing ACLs on
# metadata-only imports.
if 'access_grants' in model_data:
updated_model.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
updated_model.access_grants,
'sharing.public_models',
)
await Models.update_model_by_id(model_id, updated_model, db=db)
else:
# Insert new model
model_data['meta'] = model_data.get('meta', {})
model_data['params'] = model_data.get('params', {})
new_model = ModelForm(**model_data)
new_model.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
new_model.access_grants,
'sharing.public_models',
)
await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db)
return True
else:
@ -384,8 +431,12 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
@router.get('/model/profile/image')
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = await Models.get_model_by_id(id)
async def get_model_profile_image(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
model = await Models.get_model_by_id(id, db=db)
if model:
etag = f'"{model.updated_at}"' if model.updated_at else None

View file

@ -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
############################

View file

@ -157,7 +157,7 @@ async def send_request(
log.error(f'Failed to parse error response: {e}')
raise HTTPException(
status_code=r.status,
detail='Open WebUI: Server Connection Error',
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
r.raise_for_status()
@ -184,7 +184,7 @@ async def send_request(
except Exception as e:
raise HTTPException(
status_code=r.status if r else 500,
detail=f'Ollama: {e}' if str(e) else 'Open WebUI: Server Connection Error',
detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
finally:
if not streaming:
@ -251,7 +251,7 @@ async def verify_connection(form_data: ConnectionVerificationForm, user=Depends(
return data
except aiohttp.ClientError as e:
log.exception(f'Client error: {str(e)}')
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
except Exception as e:
log.exception(f'Unexpected error: {e}')
error_detail = f'Unexpected error: {str(e)}'
@ -428,7 +428,7 @@ async def get_filtered_models(models, user, db=None):
@router.get('/api/tags/{url_idx}')
async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
models = []
@ -621,7 +621,7 @@ async def pull_model(
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
form_data = form_data.model_dump(exclude_none=True)
form_data['model'] = form_data.get('model', form_data.get('name'))
@ -656,7 +656,7 @@ async def push_model(
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
await get_all_models(request, user=user)
@ -699,7 +699,7 @@ async def create_model(
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.debug(f'form_data: {form_data}')
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
@ -727,7 +727,7 @@ async def copy_model(
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
if url_idx is None:
await get_all_models(request, user=user)
@ -762,7 +762,7 @@ async def delete_model(
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
form_data = form_data.model_dump(exclude_none=True)
form_data['model'] = form_data.get('model', form_data.get('name'))
@ -797,7 +797,7 @@ async def delete_model(
@router.post('/api/show')
async def show_model_info(request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
form_data = form_data.model_dump(exclude_none=True)
form_data['model'] = form_data.get('model', form_data.get('name'))
@ -850,7 +850,7 @@ async def embed(
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_batch_embeddings {form_data}')
@ -909,7 +909,7 @@ async def embeddings(
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
log.info(f'generate_ollama_embeddings {form_data}')
@ -976,7 +976,7 @@ async def generate_completion(
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
# Enforce per-model access control
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
@ -1020,6 +1020,8 @@ class ChatMessage(BaseModel):
tool_calls: Optional[list[dict]] = None
images: Optional[list[str]] = None
model_config = ConfigDict(extra='allow')
@validator('content', pre=True)
@classmethod
def check_at_least_one_field(cls, field_value, values, **kwargs):
@ -1067,7 +1069,7 @@ async def generate_chat_completion(
bypass_system_prompt: bool = False,
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
@ -1313,7 +1315,7 @@ async def generate_anthropic_messages(
See https://docs.ollama.com/api/anthropic-compatibility
"""
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = {**form_data}
model_id = payload.get('model', '')
@ -1371,7 +1373,7 @@ async def generate_responses(
See https://ollama.com/blog/responses-api
"""
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail='Ollama API is disabled')
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
payload = form_data.model_dump()
model_id = form_data.model
@ -1381,29 +1383,9 @@ async def generate_responses(
if model_info.base_model_id:
payload['model'] = model_info.base_model_id
# Check if user has access to the model
if user.role == 'user':
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not (
user.id == model_info.user_id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='model',
resource_id=model_info.id,
permission='read',
user_group_ids=user_group_ids,
)
):
raise HTTPException(
status_code=403,
detail='Model not found',
)
await check_model_access(user, model_info)
else:
if user.role != 'admin':
raise HTTPException(
status_code=403,
detail='Model not found',
)
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(

View file

@ -8,7 +8,7 @@ from urllib.parse import quote, urlparse
import aiohttp
from aiocache import cached
import requests
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
@ -312,19 +312,20 @@ async def speech(request: Request, user=Depends(get_verified_user)):
r = None
try:
r = requests.post(
session = await get_session()
r = await session.post(
url=f'{url}/audio/speech',
data=body,
headers=headers,
cookies=cookies,
stream=True,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
# Save the streaming content to a file
with open(file_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
async for chunk in r.content.iter_chunked(8192):
f.write(chunk)
with open(file_body_path, 'w') as f:
@ -339,14 +340,14 @@ async def speech(request: Request, user=Depends(get_verified_user)):
detail = None
if r is not None:
try:
res = r.json()
res = await r.json()
if 'error' in res:
detail = f'External: {res["error"]}'
except Exception:
detail = f'External: {e}'
raise HTTPException(
status_code=r.status_code if r else 500,
status_code=r.status if r else 500,
detail=detail if detail else 'Open WebUI: Server Connection Error',
)
@ -691,7 +692,7 @@ async def verify_connection(
elif is_anthropic_url(url):
result = await get_anthropic_models(url, key)
if result is None:
raise HTTPException(status_code=500, detail='Failed to connect to Anthropic API')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
if 'error' in result:
raise HTTPException(status_code=500, detail=result['error'])
return result
@ -718,10 +719,10 @@ async def verify_connection(
except aiohttp.ClientError as e:
# ClientError covers all aiohttp requests issues
log.exception(f'Client error: {str(e)}')
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
except Exception as e:
log.exception(f'Unexpected error: {e}')
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
def get_azure_allowed_params(api_version: str) -> set[str]:
@ -1083,7 +1084,7 @@ async def generate_chat_completion(
else:
raise HTTPException(
status_code=404,
detail='Model not found',
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
)
# Get the API config for the model
@ -1243,7 +1244,7 @@ async def generate_chat_completion(
raise HTTPException(
status_code=r.status if r else 500,
detail='Open WebUI: Server Connection Error',
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
finally:
if not streaming:
@ -1321,7 +1322,7 @@ async def embeddings(request: Request, form_data: dict, user):
log.exception(e)
raise HTTPException(
status_code=r.status if r else 500,
detail='Open WebUI: Server Connection Error',
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
finally:
if not streaming:
@ -1445,7 +1446,7 @@ async def responses(
log.exception(e)
raise HTTPException(
status_code=r.status if r else 500,
detail='Open WebUI: Server Connection Error',
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
)
finally:
if not streaming:

View file

@ -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

View file

@ -45,6 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
# Document loaders
from open_webui.retrieval.loaders.main import Loader
@ -81,6 +82,7 @@ from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.web.ydc import search_youcom
from open_webui.retrieval.utils import (
filter_accessible_collections,
get_content_from_url,
get_embedding_function,
get_reranking_function,
@ -151,7 +153,7 @@ def get_ef(
model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS,
)
except Exception as e:
log.debug(f'Error loading SentenceTransformer: {e}')
log.error(f'Error loading SentenceTransformer: {e}')
return ef
@ -486,6 +488,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
# Reranking settings
'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL,
'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE,
'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE,
'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
@ -693,6 +696,7 @@ class ConfigForm(BaseModel):
# Reranking settings
RAG_RERANKING_MODEL: Optional[str] = None
RAG_RERANKING_ENGINE: Optional[str] = None
RAG_RERANKING_BATCH_SIZE: Optional[int] = None
RAG_EXTERNAL_RERANKER_URL: Optional[str] = None
RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None
RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None
@ -939,6 +943,12 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT
)
request.app.state.config.RAG_RERANKING_BATCH_SIZE = (
form_data.RAG_RERANKING_BATCH_SIZE
if form_data.RAG_RERANKING_BATCH_SIZE is not None
else request.app.state.config.RAG_RERANKING_BATCH_SIZE
)
log.info(
f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}'
)
@ -966,6 +976,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.rf,
reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE,
)
except Exception as e:
log.error(f'Error loading reranking model: {e}')
@ -1556,7 +1567,7 @@ async def process_file(
try:
# /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}')
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}')
except Exception:
# Audio file upload pipeline
pass
@ -1579,7 +1590,9 @@ async def process_file(
# Check if the file has already been processed and save the content
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
result = VECTOR_DB_CLIENT.query(collection_name=f'file-{file.id}', filter={'file_id': file.id})
result = await ASYNC_VECTOR_DB_CLIENT.query(
collection_name=f'file-{file.id}', filter={'file_id': file.id}
)
if result is not None and len(result.ids[0]) > 0:
docs = [
@ -1609,7 +1622,7 @@ async def process_file(
# Usage: /files/
file_path = file.path
if file_path:
file_path = Storage.get_file(file_path)
file_path = await asyncio.to_thread(Storage.get_file, file_path)
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
user=user,
@ -1643,7 +1656,7 @@ async def process_file(
MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT,
MINERU_PARAMS=request.app.state.config.MINERU_PARAMS,
)
docs = loader.load(file.filename, file.meta.get('content_type'), file_path)
docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path)
docs = [
Document(
@ -1698,7 +1711,12 @@ async def process_file(
# External embedding API takes time (5-60s+).
# Subsequent updates use fresh async sessions.
result = save_docs_to_vector_db(
# NOTE: save_docs_to_vector_db is a sync function that
# calls asyncio.run_coroutine_threadsafe(..., main_loop).result()
# which blocks the calling thread. We MUST run it in a
# worker thread to avoid deadlocking the event loop.
result = await run_in_threadpool(
save_docs_to_vector_db,
request,
docs=docs,
collection_name=collection_name,
@ -1783,6 +1801,8 @@ async def process_text(
collection_name = form_data.collection_name
if collection_name is None:
collection_name = calculate_sha256_string(form_data.content)
else:
await _validate_collection_access([collection_name], user, access_type='write')
docs = [
Document(
@ -1824,6 +1844,8 @@ async def process_web(
collection_name = form_data.collection_name
if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63]
else:
await _validate_collection_access([collection_name], user, access_type='write')
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
await run_in_threadpool(
@ -2327,32 +2349,20 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
)
async def _validate_collection_access(collection_names: list[str], user) -> None:
async def _validate_collection_access(collection_names: list[str], user, access_type: str = 'read') -> None:
"""
Prevent users from querying collections they don't own.
Enforces ownership on user-memory-* and file-* collections.
Admins bypass this check.
Raise 403 if the user lacks access to any of the requested collections.
Delegates to the shared filter_accessible_collections utility so the
access rules stay in one place.
"""
if user.role == 'admin':
return
for name in collection_names:
if name.startswith('user-memory-') and name != f'user-memory-{user.id}':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
elif name.startswith('file-'):
file_id = name[len('file-') :]
if not await has_access_to_file(
file_id=file_id,
access_type='read',
user=user,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
requested = set(collection_names)
allowed = await filter_accessible_collections(requested, user, access_type=access_type)
denied = requested - allowed
if denied:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
class QueryDocForm(BaseModel):
@ -2375,7 +2385,7 @@ async def query_doc_handler(
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid):
collection_results = {}
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
collection_results[form_data.collection_name] = await ASYNC_VECTOR_DB_CLIENT.get(
collection_name=form_data.collection_name
)
return await query_doc_with_hybrid_search(
@ -2404,7 +2414,10 @@ async def query_doc_handler(
query_embedding = await request.app.state.EMBEDDING_FUNCTION(
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
)
return query_doc(
# query_doc wraps a blocking VECTOR_DB_CLIENT.search call;
# offload so the request's event loop stays responsive.
return await asyncio.to_thread(
query_doc,
collection_name=form_data.collection_name,
query_embedding=query_embedding,
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
@ -2502,7 +2515,7 @@ async def delete_entries_from_collection(
db: AsyncSession = Depends(get_async_session),
):
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
file = await Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
@ -2511,13 +2524,37 @@ async def delete_entries_from_collection(
)
hash = file.hash
VECTOR_DB_CLIENT.delete(
# Refuse to issue a `filter={'hash': None}` query — the
# match semantics of a null filter value are
# backend-dependent (some backends ignore the key, some
# match every row whose metadata lacks `hash`) and risk
# deleting unrelated entries. Files without a hash are
# typically unprocessed / failed / legacy records that
# can't be targeted by hash anyway.
if hash is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('File has no hash; cannot delete vector entries by hash.'),
)
# Pre-existing bug: this used `metadata=` which is not a
# parameter on `VectorDBBase.delete` nor on any backend
# implementation, so the call always raised TypeError that
# was silently swallowed by the surrounding `except
# Exception` and the endpoint reported `{'status': False}`
# for every request. Use `filter` to actually do what the
# endpoint name promises.
await ASYNC_VECTOR_DB_CLIENT.delete(
collection_name=form_data.collection_name,
metadata={'hash': hash},
filter={'hash': hash},
)
return {'status': True}
else:
return {'status': False}
except HTTPException:
# Caller-meaningful errors (404/400 above) must not be
# swallowed and re-shaped as `{'status': False}`.
raise
except Exception as e:
log.exception(e)
return {'status': False}
@ -2525,7 +2562,7 @@ async def delete_entries_from_collection(
@router.post('/reset/db')
async def reset_vector_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
VECTOR_DB_CLIENT.reset()
await ASYNC_VECTOR_DB_CLIENT.reset()
await Knowledges.delete_all_knowledge(db=db)

View file

@ -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

View file

@ -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))
############################

View file

@ -276,14 +276,8 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi
async def get_user_settings_by_session_user(
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user.settings
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user.settings
############################
@ -339,14 +333,8 @@ async def get_user_status_by_session_user(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user
############################
@ -366,15 +354,14 @@ async def update_user_status_by_session_user(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = await Users.get_user_by_id(user.id, db=db)
if user:
user = await Users.update_user_status_by_id(user.id, form_data, db=db)
return user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
if updated:
return updated
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
@ -384,14 +371,8 @@ async def update_user_status_by_session_user(
@router.get('/user/info', response_model=Optional[dict])
async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user.info
############################
@ -403,19 +384,13 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
async def update_user_info_by_session_user(
form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
user = await Users.get_user_by_id(user.id, db=db)
if user:
if user.info is None:
user.info = {}
user = await Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db)
if user:
return user.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# Merges against the auth-time snapshot of user.info. The previous pre-merge
# refetch only narrowed (did not eliminate) the lost-update window on concurrent
# same-user writes; real safety needs row locking or a version column.
existing_info = user.info or {}
updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db)
if updated:
return updated.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -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'),
)

View file

@ -697,6 +697,31 @@ async def yjs_document_update(sid, data):
log.warning(f'Session {sid} not in room {room}. Rejecting update.')
return
# Verify write permission — room membership only proves read access
user = SESSION_POOL.get(sid)
if not user:
return
if document_id.startswith('note:'):
note_id = document_id.split(':')[1]
note = await Notes.get_note_by_id(note_id)
if not note:
log.error(f'Note {note_id} not found')
return
if (
user.get('role') != 'admin'
and user.get('id') != note.user_id
and not await AccessGrants.has_access(
user_id=user.get('id'),
resource_type='note',
resource_id=note.id,
permission='write',
)
):
log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.')
return
try:
await stop_item_tasks(REDIS, document_id)
except Exception:
@ -724,10 +749,6 @@ async def yjs_document_update(sid, data):
skip_sid=sid,
)
user = SESSION_POOL.get(sid)
if not user:
return
async def debounced_save():
await asyncio.sleep(0.5)
await document_save_handler(document_id, data.get('data', {}), user)
@ -743,7 +764,7 @@ async def yjs_document_update(sid, data):
async def yjs_document_leave(sid, data):
"""Handle user leaving a document"""
try:
document_id = data['document_id']
document_id = normalize_document_id(data['document_id'])
user_id = data.get('user_id', sid)
log.info(f'User {user_id} leaving document {document_id}')

View file

@ -37,7 +37,7 @@ from open_webui.models.channels import Channels, ChannelMember, Channel
from open_webui.models.messages import Messages, Message
from open_webui.models.groups import Groups
from open_webui.models.memories import Memories
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@ -653,7 +653,7 @@ async def delete_memory(
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
if result:
VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
return json.dumps(
{'status': 'success', 'message': f'Memory {memory_id} deleted'},
ensure_ascii=False,
@ -2202,7 +2202,7 @@ async def query_knowledge_bases(
import heapq
from open_webui.models.knowledge import Knowledges
from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
@ -2227,7 +2227,7 @@ async def query_knowledge_bases(
accessible_ids = [kb.id for kb in accessible_knowledge_bases.items]
search_results = VECTOR_DB_CLIENT.search(
search_results = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=KNOWLEDGE_BASES_COLLECTION,
vectors=[query_embedding],
filter={'knowledge_base_id': {'$in': accessible_ids}},
@ -2494,7 +2494,6 @@ async def create_automation(
name: str,
prompt: str,
rrule: str,
model_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
@ -2502,6 +2501,7 @@ async def create_automation(
"""
Create a scheduled automation that runs a prompt on a recurring or one-time schedule.
Use this when the user wants to schedule a task to run automatically.
The automation will use the current chat model.
The rrule parameter must be a valid iCalendar RRULE string. Common examples:
- Every day at 9am: "DTSTART:20250101T090000\\nRRULE:FREQ=DAILY"
@ -2516,7 +2516,6 @@ async def create_automation(
:param name: A short descriptive name for the automation
:param prompt: The prompt/instructions to execute on each run
:param rrule: An iCalendar RRULE string defining the schedule
:param model_id: Optional model ID to use. Defaults to the current chat model if omitted.
:return: JSON with the created automation details including id, next scheduled runs
"""
if __request__ is None:
@ -2535,11 +2534,10 @@ async def create_automation(
if not user:
return json.dumps({'error': 'User not found'})
# Default to current chat's model if not specified
# Always use the calling model for the automation
model_id = (__metadata__ or {}).get('model_id')
if not model_id:
model_id = (__metadata__ or {}).get('model_id') or (__metadata__ or {}).get('model')
if not model_id:
return json.dumps({'error': 'model_id is required (could not detect current model)'})
return json.dumps({'error': 'Could not detect current model'})
# Validate the RRULE
try:

View file

@ -257,6 +257,47 @@ async def filter_allowed_access_grants(
return access_grants
async def has_base_model_access(
user_id: str,
model_info,
*,
user_group_ids: set[str] | None = None,
db=None,
) -> bool:
"""
Walk the ``base_model_id`` chain and verify the caller has read access
at every hop.
Returns ``True`` when access is granted (or the chain ends at a raw
provider model that has no per-model ACL). Returns ``False`` the
moment a registered base model denies access.
"""
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
base_model_id = getattr(model_info, 'base_model_id', None)
seen = {model_info.id}
while base_model_id and base_model_id not in seen:
seen.add(base_model_id)
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
if base_model_info is None:
break # Raw provider model — no per-model ACL
if not (
user_id == base_model_info.user_id
or await AccessGrants.has_access(
user_id=user_id,
resource_type='model',
resource_id=base_model_info.id,
permission='read',
user_group_ids=user_group_ids,
db=db,
)
):
return False
base_model_id = getattr(base_model_info, 'base_model_id', None)
return True
async def check_model_access(
user: UserModel,
model_info,
@ -296,6 +337,10 @@ async def check_model_access(
)
):
raise HTTPException(status_code=403, detail='Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
raise HTTPException(status_code=403, detail='Model not found')
else:
if user.role != 'admin':
raise HTTPException(status_code=403, detail='Model not found')

View file

@ -66,10 +66,18 @@ async def has_access_to_file(
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = await Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db)
if shared_chat_ids:
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='shared_chat',
resource_ids=shared_chat_ids,
permission='read',
user_group_ids=user_group_ids,
db=db,
)
if accessible_ids:
return True
# Check if the file is directly attached to a shared workspace model
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):

View file

@ -181,17 +181,99 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
)
elif block_type == 'tool_result':
# Tool results become separate tool messages in OpenAI format
tool_content = block.get('content', '')
if isinstance(tool_content, list):
tool_text_parts = []
for tc in tool_content:
if isinstance(tc, dict) and tc.get('type') == 'text':
tool_text_parts.append(tc.get('text', ''))
tool_content = '\n'.join(tool_text_parts)
tool_result_content = block.get('content', '')
tool_content: str | list = ''
if isinstance(tool_result_content, str):
tool_content = tool_result_content
elif isinstance(tool_result_content, list):
# Build a multimodal content array to preserve
# images and other non-text content types.
converted_parts = []
for content_block in tool_result_content:
if not isinstance(content_block, dict):
continue
content_type = content_block.get('type', 'text')
if content_type == 'text':
converted_parts.append(
{
'type': 'text',
'text': content_block.get('text', ''),
}
)
elif content_type == 'image':
source = content_block.get('source', {})
if source.get('type') == 'base64':
media_type = source.get('media_type', 'image/png')
data = source.get('data', '')
converted_parts.append(
{
'type': 'image_url',
'image_url': {
'url': f'data:{media_type};base64,{data}',
},
}
)
elif source.get('type') == 'url':
converted_parts.append(
{
'type': 'image_url',
'image_url': {
'url': source.get('url', ''),
},
}
)
elif content_type == 'document':
# Documents have no direct OpenAI equivalent;
# convert to a text representation.
document_source = content_block.get('source', {})
document_title = content_block.get('title', 'Document')
document_context = content_block.get('context', '')
document_text = f'[Document: {document_title}]'
if document_context:
document_text += f'\n{document_context}'
if document_source.get('type') == 'text' and document_source.get('data'):
document_text += f'\n{document_source["data"]}'
converted_parts.append({'type': 'text', 'text': document_text})
elif content_type == 'search_result':
# Convert search results to a text
# representation with source attribution.
search_title = content_block.get('title', '')
search_url = content_block.get('source', '')
search_content_blocks = content_block.get('content', [])
search_texts = []
for search_block in search_content_blocks:
if isinstance(search_block, dict) and search_block.get('type') == 'text':
search_texts.append(search_block.get('text', ''))
search_body = '\n'.join(search_texts)
search_text = f'[Search Result: {search_title}]'
if search_url:
search_text += f'\nSource: {search_url}'
if search_body:
search_text += f'\n{search_body}'
converted_parts.append({'type': 'text', 'text': search_text})
# Flatten to string when only text parts are present
if all(part.get('type') == 'text' for part in converted_parts):
tool_content = '\n'.join(part.get('text', '') for part in converted_parts)
elif converted_parts:
tool_content = converted_parts
else:
tool_content = ''
# Propagate error status if present
if block.get('is_error'):
tool_content = f'Error: {tool_content}'
if isinstance(tool_content, str):
tool_content = f'Error: {tool_content}'
elif isinstance(tool_content, list):
tool_content.insert(
0,
{
'type': 'text',
'text': 'Error: ',
},
)
messages.append(
{

View file

@ -0,0 +1,268 @@
"""
Pure-ASGI replacements for the project's previous
`@app.middleware('http')` / `BaseHTTPMiddleware` middlewares.
Why this matters
----------------
Starlette's `BaseHTTPMiddleware` (which `@app.middleware('http')` is
sugar for) runs the downstream app inside an `anyio` task group. When
the wrapper exits for any reason: response complete, client
disconnect, an outer middleware bailing out the task group cancels
the inner task. That `CancelledError` then propagates into whatever
the inner task was doing, including in-flight DB queries, embedding
calls and disk I/O.
In Open WebUI this surfaces as:
* SQLAlchemy logging multi-page `NotImplementedError:
terminate_force_close()` tracebacks at ERROR every time a request is
cancelled mid-DB-call (the aiosqlite connector cleanup path).
* Spurious cancellations cascading through the four stacked
`@app.middleware('http')` wrappers.
Pure ASGI middleware does not introduce a cancel scope around the
downstream app, so client disconnects propagate the way ASGI was
designed to (via `receive()` returning `http.disconnect`) instead of
being injected as `CancelledError` into arbitrary `await` points.
Reference: https://www.starlette.io/middleware/#limitations
"""
from __future__ import annotations
import logging
import re
import time
from urllib.parse import parse_qs, urlencode
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPAuthorizationCredentials
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from open_webui.internal.db import ScopedSession
from open_webui.utils.auth import get_http_authorization_cred
log = logging.getLogger(__name__)
class CommitSessionMiddleware:
"""Commit and release the thread-local sync `ScopedSession` after each
HTTP request.
Most requests now use the async session; the sync ScopedSession is
only touched by startup, healthchecks, and a handful of legacy
helpers (notably the pgvector / opengauss vector-DB clients). The
middleware exists so that PostgreSQL connections do not accumulate
as "idle in transaction" and so that any pending sync work made
inside the request is durably persisted.
Failure semantics
-----------------
* Downstream raised roll back any pending sync work, release the
connection, and re-raise so the outer exception middleware can
turn it into an error response. We never commit work on a
request that did not complete successfully.
* Downstream returned commit pending sync work; on commit
failure, log loudly, roll back, and re-raise. Note that in pure
ASGI the response messages have already been emitted by the
time `await self.app(...)` returns, so a commit failure cannot
retroactively change what the client sees on the wire but
re-raising still surfaces the error in logs and to ASGI servers
that expose it. We deliberately do not buffer the response to
gate it on commit success, because that would defeat streaming
responses (chat completions, SSE) which are core to the app.
For request paths where commit-before-send is required, manage the
sync session explicitly inside the handler instead of relying on
this middleware.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
try:
await self.app(scope, receive, send)
except BaseException:
# Downstream did not complete successfully. Roll back any
# pending sync writes, release the connection, and let the
# exception propagate.
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
finally:
ScopedSession.remove()
raise
# Downstream completed. Commit pending sync work.
try:
ScopedSession.commit()
except Exception:
log.exception('CommitSessionMiddleware: post-request commit failed; response was already sent to client')
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after commit failure')
raise
finally:
# CRITICAL: remove() returns the connection to the pool.
# Without this, connections remain "checked out" and
# accumulate as "idle in transaction" in PostgreSQL.
ScopedSession.remove()
class AuthTokenMiddleware:
"""Extract the bearer/cookie/x-api-key credential and stash it on
`request.state.token`.
Routes that depend on `get_verified_user` etc. read this state.
Also exposes `request.state.enable_api_keys` (snapshotted at request
entry from runtime config) and stamps an `X-Process-Time` response
header.
"""
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
self.app = app
self._fastapi_app = fastapi_app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
start_time = time.monotonic()
request = Request(scope)
token = get_http_authorization_cred(request.headers.get('Authorization'))
if token is None:
cookie_token = request.cookies.get('token')
if cookie_token:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token)
if token is None:
api_key = request.headers.get('x-api-key')
if api_key:
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
request.state.token = token
request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS
async def send_with_timing(message: Message) -> None:
if message['type'] == 'http.response.start':
process_time = int(time.monotonic() - start_time)
headers = MutableHeaders(scope=message)
headers['X-Process-Time'] = str(process_time)
await send(message)
await self.app(scope, receive, send_with_timing)
class WebsocketUpgradeGuardMiddleware:
"""Reject HTTP requests to `/ws/socket.io` that claim
`transport=websocket` but lack the proper `Upgrade`/`Connection`
headers.
Works around https://github.com/miguelgrinberg/python-engineio/issues/367
where engineio mishandles such requests.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
path = scope.get('path', '')
if '/ws/socket.io' in path:
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
query_params = parse_qs(query_string)
if query_params.get('transport', [''])[0] == 'websocket':
headers = _scope_headers(scope)
upgrade = headers.get('upgrade', '').lower()
connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')]
if upgrade != 'websocket' or 'upgrade' not in connection_tokens:
response = JSONResponse(
status_code=400,
content={'detail': 'Invalid WebSocket upgrade request'},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
class RedirectMiddleware:
"""Rewrites a couple of legacy entry-points to the SPA's own routes:
* ``GET /watch?v=ID`` (YouTube) ``/?youtube=ID``
* ``GET /?shared=`` (PWA share-target) ``/?youtube=`` /
``/?load-url=`` / ``/?q=``
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http' or scope.get('method', '').upper() != 'GET':
await self.app(scope, receive, send)
return
path = scope.get('path', '')
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
query_params = parse_qs(query_string)
redirect_params: dict[str, str] = {}
if path.endswith('/watch') and 'v' in query_params and query_params['v']:
redirect_params['youtube'] = query_params['v'][0]
if 'shared' in query_params and query_params['shared']:
text = query_params['shared'][0]
if text:
url_match = re.match(r'https://\S+', text)
if url_match:
# Local import: youtube loader pulls heavy deps and is
# only needed when a share-target actually contains a
# YouTube URL.
from open_webui.retrieval.loaders.youtube import _parse_video_id
youtube_video_id = _parse_video_id(url_match[0])
if youtube_video_id:
redirect_params['youtube'] = youtube_video_id
else:
redirect_params['load-url'] = url_match[0]
else:
redirect_params['q'] = text
if redirect_params:
redirect_url = f'/?{urlencode(redirect_params)}'
response = RedirectResponse(url=redirect_url)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _scope_headers(scope: Scope) -> dict[str, str]:
"""Return ASGI scope headers as a lower-cased str→str dict.
ASGI delivers headers as a list of (bytes, bytes) pairs. For
convenience, fold duplicate keys with comma-joining (matching
HTTP/1.1 semantics).
"""
decoded: dict[str, str] = {}
for raw_key, raw_value in scope.get('headers', []):
key = raw_key.decode('latin-1').lower()
value = raw_value.decode('latin-1')
if key in decoded:
decoded[key] = f'{decoded[key]}, {value}'
else:
decoded[key] = value
return decoded

View file

@ -22,6 +22,7 @@ from dateutil.rrule import rrulestr
from fastapi import Request
from starlette.datastructures import Headers
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.automations import Automations, AutomationRuns, AutomationModel
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.users import Users
@ -59,9 +60,9 @@ def validate_rrule(s: str) -> None:
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(f'Invalid RRULE: {e}')
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if rule.after(datetime.now()) is None:
raise ValueError('RRULE has no future occurrences')
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
@ -306,8 +307,9 @@ async def execute_automation(app, automation: AutomationModel) -> None:
user_msg_id = str(uuid4())
assistant_msg_id = str(uuid4())
# Create the chat with user message (same structure as frontend)
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
automation.user_id,
ChatForm(
chat={
@ -377,7 +379,13 @@ async def execute_automation(app, automation: AutomationModel) -> None:
'stream': True,
'chat_id': chat.id,
'id': assistant_msg_id,
'parent_id': user_msg_id,
'parent_id': None, # Root message (chat already created above)
'user_message': {
'id': user_msg_id,
'parentId': None,
'role': 'user',
'content': prompt,
},
'session_id': f'automation:{automation.id}',
'background_tasks': {},
}

View file

@ -20,12 +20,14 @@ from open_webui.models.files import Files
from open_webui.routers.files import upload_file_handler
from open_webui.retrieval.web.utils import validate_url
import asyncio
import mimetypes
import base64
import io
import re
import requests
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.utils.session_pool import get_session
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
@ -37,19 +39,20 @@ async def get_image_base64_from_url(url: str) -> Optional[str]:
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(url)
# Download the image from the URL
response = requests.get(url)
response.raise_for_status()
image_data = response.content
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
session = await get_session()
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response:
response.raise_for_status()
image_data = await response.read()
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
else:
file = await Files.get_file_by_id(url)
if not file:
return None
file_path = Storage.get_file(file.path)
file_path = await asyncio.to_thread(Storage.get_file, file.path)
file_path = Path(file_path)
if file_path.is_file():
@ -68,7 +71,7 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
image_url = ''
# Extract base64 image data from the line
image_data, content_type = get_image_data(base64_image_string)
image_data, content_type = await get_image_data(base64_image_string)
if image_data is not None:
_, image_url = await upload_image(
request,
@ -170,7 +173,7 @@ async def get_image_base64_from_file_id(id: str) -> Optional[str]:
return None
try:
file_path = Storage.get_file(file.path)
file_path = await asyncio.to_thread(Storage.get_file, file.path)
file_path = Path(file_path)
# Check if the file already exists in the cache

View file

@ -1,49 +1,51 @@
import asyncio
import json
import logging
import random
import requests
import aiohttp
import urllib.parse
import urllib.request
from typing import Optional
import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client)
import aiohttp
from pydantic import BaseModel
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.utils.session_pool import get_session
log = logging.getLogger(__name__)
default_headers = {'User-Agent': 'Mozilla/5.0'}
def queue_prompt(prompt, client_id, base_url, api_key):
async def queue_prompt(prompt, client_id, base_url, api_key):
log.info('queue_prompt')
p = {'prompt': prompt, 'client_id': client_id}
data = json.dumps(p).encode('utf-8')
log.debug(f'queue_prompt data: {data}')
log.debug(f'queue_prompt data: {p}')
try:
req = urllib.request.Request(
session = await get_session()
async with session.post(
f'{base_url}/prompt',
data=data,
json=p,
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
response = urllib.request.urlopen(req).read()
return json.loads(response)
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.json()
except Exception as e:
log.exception(f'Error while queuing prompt: {e}')
raise e
raise
def get_image(filename, subfolder, folder_type, base_url, api_key):
async def get_image(filename, subfolder, folder_type, base_url, api_key):
log.info('get_image')
data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type}
url_values = urllib.parse.urlencode(data)
req = urllib.request.Request(
session = await get_session()
async with session.get(
f'{base_url}/view?{url_values}',
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
with urllib.request.urlopen(req) as response:
return response.read()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.read()
def get_image_url(filename, subfolder, folder_type, base_url):
@ -53,32 +55,39 @@ def get_image_url(filename, subfolder, folder_type, base_url):
return f'{base_url}/view?{url_values}'
def get_history(prompt_id, base_url, api_key):
async def get_history(prompt_id, base_url, api_key):
log.info('get_history')
req = urllib.request.Request(
session = await get_session()
async with session.get(
f'{base_url}/history/{prompt_id}',
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
with urllib.request.urlopen(req) as response:
return json.loads(response.read())
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.json()
def get_images(ws, workflow, client_id, base_url, api_key):
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id']
async def _ws_get_images(ws, workflow, client_id, base_url, api_key):
"""Queue a prompt and wait on *ws* for ComfyUI to finish executing it.
Returns a dict of ``{'data': [{'url': ...}, ...]}``.
"""
prompt_id = (await queue_prompt(workflow, client_id, base_url, api_key))['prompt_id']
output_images = []
while True:
out = ws.recv()
if isinstance(out, str):
message = json.loads(out)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
message = json.loads(msg.data)
if message['type'] == 'executing':
data = message['data']
if data['node'] is None and data['prompt_id'] == prompt_id:
break # Execution is done
else:
continue # previews are binary data
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
log.error(f'WebSocket closed unexpectedly: {msg.type}')
break
# binary messages (previews) are silently skipped
history = get_history(prompt_id, base_url, api_key)[prompt_id]
history = (await get_history(prompt_id, base_url, api_key))[prompt_id]
for node_id in history['outputs']:
node_output = history['outputs'][node_id]
if node_id in workflow and workflow[node_id].get('class_type') in [
@ -105,10 +114,10 @@ async def comfyui_upload_image(image_file_item, base_url, api_key):
form.add_field('image', file_bytes, filename=filename, content_type=mime_type)
form.add_field('type', 'input') # required by ComfyUI
async with aiohttp.ClientSession() as session:
async with session.post(url, data=form, headers=headers) as resp:
resp.raise_for_status()
return await resp.json()
session = await get_session()
async with session.post(url, data=form, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
resp.raise_for_status()
return await resp.json()
class ComfyUINodeInput(BaseModel):
@ -136,11 +145,9 @@ class ComfyUICreateImageForm(BaseModel):
seed: Optional[int] = None
async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key):
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
workflow = json.loads(payload.workflow.workflow)
for node in payload.workflow.nodes:
def _apply_workflow_nodes(workflow, nodes, model, payload):
"""Mutate *workflow* dict in-place based on typed node definitions."""
for node in nodes:
if node.type:
if node.type == 'model':
for node_id in node.node_ids:
@ -151,6 +158,14 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
elif node.type == 'negative_prompt':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
elif node.type == 'image':
if isinstance(payload.image, list):
for idx, node_id in enumerate(node.node_ids):
if idx < len(payload.image):
workflow[node_id]['inputs'][node.key] = payload.image[idx]
else:
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = payload.image
elif node.type == 'width':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
@ -171,24 +186,31 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = node.value
async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key):
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
workflow = json.loads(payload.workflow.workflow)
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
headers = {'Authorization': f'Bearer {api_key}'}
session = await get_session()
try:
ws = websocket.WebSocket()
headers = {'Authorization': f'Bearer {api_key}'}
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
log.info('WebSocket connection established.')
except Exception as e:
async with session.ws_connect(
f'{ws_url}/ws?clientId={client_id}',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as ws:
log.info('WebSocket connection established.')
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
except aiohttp.WSServerHandshakeError as e:
log.exception(f'Failed to connect to WebSocket server: {e}')
return None
try:
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
except Exception as e:
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
log.exception(f'Error during image generation: {e}')
return None
return images
@ -209,64 +231,26 @@ class ComfyUIEditImageForm(BaseModel):
async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key):
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
workflow = json.loads(payload.workflow.workflow)
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
for node in payload.workflow.nodes:
if node.type:
if node.type == 'model':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = model
elif node.type == 'image':
if isinstance(payload.image, list):
# check if multiple images are provided
for idx, node_id in enumerate(node.node_ids):
if idx < len(payload.image):
workflow[node_id]['inputs'][node.key] = payload.image[idx]
else:
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = payload.image
elif node.type == 'prompt':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt
elif node.type == 'negative_prompt':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
elif node.type == 'width':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
elif node.type == 'height':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height
elif node.type == 'n':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n
elif node.type == 'steps':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps
elif node.type == 'seed':
seed = payload.seed if payload.seed else random.randint(0, 1125899906842624)
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = seed
else:
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = node.value
headers = {'Authorization': f'Bearer {api_key}'}
session = await get_session()
try:
ws = websocket.WebSocket()
headers = {'Authorization': f'Bearer {api_key}'}
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
log.info('WebSocket connection established.')
except Exception as e:
async with session.ws_connect(
f'{ws_url}/ws?clientId={client_id}',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as ws:
log.info('WebSocket connection established.')
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
except aiohttp.WSServerHandshakeError as e:
log.exception(f'Failed to connect to WebSocket server: {e}')
return None
try:
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
except Exception as e:
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
log.exception(f'Error during image editing: {e}')
return None
return images

View file

@ -401,12 +401,61 @@ def is_opening_code_block(content):
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
_OPENAI_TOOL_DISPLAY_NAMES = {
'web_search_call': 'Web Search',
'file_search_call': 'File Search',
'computer_call': 'Computer Use',
}
def _render_openai_tool_call_handler(item: dict, done: bool) -> str:
"""Render an OpenAI Responses API server-side tool item as a <details> block.
Handles web_search_call, file_search_call, and computer_call items whose
schemas are defined in the openai-python SDK (generated from OpenAPI spec).
"""
item_type = item.get('type', '')
call_id = item.get('id', '')
display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type)
# Build a short summary of what the tool did
summary = ''
if item_type == 'web_search_call':
action = item.get('action', {})
if isinstance(action, dict):
atype = action.get('type', '')
if atype == 'search':
queries = action.get('queries') or []
query = action.get('query', '')
summary = f'Search: {", ".join(str(q) for q in queries)}' if queries else (f'Search: {query}' if query else '')
elif atype == 'open_page':
summary = f'Open page: {action.get("url", "")}' if action.get('url') else ''
elif atype == 'find_in_page':
summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else ''
elif item_type == 'file_search_call':
queries = item.get('queries', [])
if queries:
summary = f'Queries: {", ".join(str(q) for q in queries)}'
elif item_type == 'computer_call':
action = item.get('action')
actions = item.get('actions')
if isinstance(action, dict):
summary = f'Action: {action.get("type", "unknown")}'
elif isinstance(actions, list) and actions:
summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}'
escaped_name = html.escape(display_name)
if done:
return f'<details type="tool_calls" done="true" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Tool Executed</summary>\n{html.escape(summary)}\n</details>\n'
return f'<details type="tool_calls" done="false" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Executing...</summary>\n</details>\n'
def serialize_output(output: list) -> str:
"""
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
content = ''
parts: list[str] = []
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
@ -423,46 +472,48 @@ def serialize_output(output: list) -> str:
if 'text' in content_part:
text = content_part.get('text', '').strip()
if text:
content = f'{content}{text}\n'
parts.append(text)
elif item_type == 'function_call':
# Render tool call inline with its result (if available)
if content and not content.endswith('\n'):
content += '\n'
call_id = item.get('call_id', '')
name = item.get('name', '')
arguments = item.get('arguments', '')
result_item = tool_outputs.get(call_id)
if result_item:
result_text = ''
result_parts: list[str] = []
for result_output in result_item.get('output', []):
if 'text' in result_output:
output_text = result_output.get('text', '')
result_text += str(output_text) if not isinstance(output_text, str) else output_text
result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text)
result_text = ''.join(result_parts)
files = result_item.get('files')
embeds = result_item.get('embeds', '')
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" result="{html.escape(json.dumps(result_text, ensure_ascii=False))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
parts.append(f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n</details>')
else:
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
parts.append(f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>')
elif item_type == 'function_call_output':
# Already handled inline with function_call above
pass
elif item_type in _OPENAI_TOOL_DISPLAY_NAMES:
status = item.get('status', 'in_progress')
done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1
parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n'))
elif item_type == 'reasoning':
reasoning_content = ''
reasoning_parts: list[str] = []
# Check for 'summary' (new structure) or 'content' (legacy/fallback)
source_list = item.get('summary', []) or item.get('content', [])
for content_part in source_list:
if 'text' in content_part:
reasoning_content += content_part.get('text', '')
reasoning_parts.append(content_part.get('text', ''))
elif 'summary' in content_part: # Handle potential nested logic if any
pass
reasoning_content = reasoning_content.strip()
reasoning_content = ''.join(reasoning_parts).strip()
duration = item.get('duration')
status = item.get('status', 'in_progress')
@ -471,9 +522,6 @@ def serialize_output(output: list) -> str:
# render as done (a subsequent item means reasoning is complete)
is_last_item = idx == len(output) - 1
if content and not content.endswith('\n'):
content += '\n'
display = html.escape(
'\n'.join(
(f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines()
@ -481,19 +529,22 @@ def serialize_output(output: list) -> str:
)
if status == 'completed' or duration is not None or not is_last_item:
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
parts.append(f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>')
else:
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
parts.append(f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>')
elif item_type == 'open_webui:code_interpreter':
# Code interpreter needs to inspect/mutate prior accumulated content
# to strip trailing unclosed code fences — materialize only here.
content = '\n'.join(parts)
content_stripped, original_whitespace = split_content_and_whitespace(content)
if is_opening_code_block(content_stripped):
content = content_stripped.rstrip('`').rstrip() + original_whitespace
else:
content = content_stripped + original_whitespace
if content and not content.endswith('\n'):
content += '\n'
# Re-split back into parts list after mutation
parts = [content] if content else []
# Render the code_interpreter item as a <details> block
# so the frontend Collapsible renders "Analyzing..."/"Analyzed".
@ -519,11 +570,11 @@ def serialize_output(output: list) -> str:
output_attr = f' output="{html.escape(output_json)}"'
if status == 'completed' or duration is not None or not is_last_item:
content += f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
parts.append(f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>')
else:
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
parts.append(f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>')
return content.strip()
return '\n'.join(parts).strip()
def deep_merge(target, source):
@ -2054,13 +2105,16 @@ async def convert_url_images_to_base64(form_data):
continue
try:
base64_data = await asyncio.to_thread(get_image_base64_from_url, image_url)
new_content.append(
{
'type': 'image_url',
'image_url': {'url': base64_data},
}
)
base64_data = await get_image_base64_from_url(image_url)
if base64_data:
new_content.append(
{
'type': 'image_url',
'image_url': {'url': base64_data},
}
)
else:
new_content.append(item)
except Exception as e:
log.debug(f'Error converting image URL to base64: {e}')
new_content.append(item)
@ -2150,10 +2204,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Load messages from DB when available — DB preserves structured 'output' items
# which the frontend strips, causing tool calls to be merged into content.
chat_id = metadata.get('chat_id')
parent_message_id = metadata.get('parent_message_id')
user_message_id = metadata.get('user_message_id')
if chat_id and parent_message_id and not chat_id.startswith('local:'):
db_messages = await load_messages_from_db(chat_id, parent_message_id)
if chat_id and user_message_id and not chat_id.startswith('local:'):
db_messages = await load_messages_from_db(chat_id, user_message_id)
if db_messages:
system_message = get_system_message(form_data.get('messages', []))
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
@ -2457,6 +2511,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
metadata = {
**metadata,
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'terminal_id': terminal_id,
'files': files,
@ -2615,7 +2670,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Resolve terminal tools if terminal_id is set (outside tool_ids check
# so system terminals work even when no other tools are selected)
if terminal_id:
terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True)
if terminal_id and terminal_capability:
try:
terminal_result = await get_terminal_tools(
request,
@ -3058,6 +3114,147 @@ async def background_tasks_handler(ctx):
pass
async def outlet_filter_handler(ctx):
"""Run outlet filters inline after chat completion.
Replaces the separate POST /api/chat/completed round-trip.
Persists outlet-modified content to DB and emits a chat:outlet event
so the frontend can sync its in-memory state.
For temp chats (local: prefix), messages are built from form_data
plus the assistant response message stored in ctx['assistant_message'],
since temp chats have no DB-persisted history.
"""
request = ctx['request']
user = ctx['user']
model = ctx['model']
metadata = ctx['metadata']
event_emitter = ctx.get('event_emitter')
event_caller = ctx.get('event_caller')
chat_id = metadata.get('chat_id', '')
message_id = metadata.get('message_id')
if not chat_id or not message_id:
return
is_temp_chat = chat_id.startswith('local:')
try:
messages_map = None
if is_temp_chat:
# Temp chats have no DB record — build message list from
# the in-memory form_data plus the assistant response.
form_messages = ctx.get('form_data', {}).get('messages', [])
assistant_message = ctx.get('assistant_message', {})
message_list = [
{
'role': m.get('role'),
'content': m.get('content', ''),
}
for m in form_messages
]
# Append the full assistant message (content, output, usage, etc.)
if assistant_message:
message_list.append(
{
'id': message_id,
'role': 'assistant',
**assistant_message,
}
)
else:
messages_map = await Chats.get_messages_map_by_chat_id(chat_id)
if not messages_map:
return
message_list = get_message_list(messages_map, message_id)
if not message_list:
return
model_id = model.get('id') if isinstance(model, dict) else model
outlet_data = {
'model': model_id,
'messages': [
{
'id': m.get('id'),
'role': m.get('role'),
'content': m.get('content', ''),
'info': m.get('info'),
'timestamp': m.get('timestamp'),
**({'output': m['output']} if m.get('output') else {}),
**({'usage': m['usage']} if m.get('usage') else {}),
**({'sources': m['sources']} if m.get('sources') else {}),
}
for m in message_list
],
'filter_ids': metadata.get('filter_ids', []),
'chat_id': chat_id,
'session_id': metadata.get('session_id'),
'id': message_id,
}
# Pipeline outlet filters
models = request.app.state.MODELS
try:
outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models)
except Exception as e:
log.debug(f'Pipeline outlet filter error: {e}')
# Function outlet filters
extra_params = {
'__event_emitter__': event_emitter,
'__event_call__': event_caller,
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
'__metadata__': metadata,
'__request__': request,
'__model__': model,
}
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
filter_functions = await Functions.get_functions_by_ids(filter_ids)
outlet_result, _ = await process_filter_functions(
request=request,
filter_functions=filter_functions,
filter_type='outlet',
form_data=outlet_data,
extra_params=extra_params,
)
# Persist outlet-modified content and notify frontend
# (skip DB persistence for temp chats — they have no DB record)
if outlet_result and outlet_result.get('messages'):
if not is_temp_chat and messages_map:
for message in outlet_result['messages']:
outlet_message_id = message.get('id')
if outlet_message_id and outlet_message_id in messages_map:
original_message = messages_map[outlet_message_id]
if original_message.get('content') != message.get('content'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
outlet_message_id,
{
'content': message['content'],
'originalContent': original_message.get('content'),
},
)
if event_emitter:
await event_emitter(
{
'type': 'chat:outlet',
'data': {'messages': outlet_result['messages']},
}
)
except Exception as e:
log.debug(f'Error running outlet filters: {e}')
async def non_streaming_chat_response_handler(response, ctx):
request = ctx['request']
@ -3169,7 +3366,7 @@ async def non_streaming_chat_response_handler(response, ctx):
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}',
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
@ -3179,6 +3376,12 @@ async def non_streaming_chat_response_handler(response, ctx):
)
await background_tasks_handler(ctx)
ctx['assistant_message'] = {
'content': content,
'output': response_output,
**({'usage': usage} if usage else {}),
}
await outlet_filter_handler(ctx)
response = build_response_object(response, merge_events_into_response(response_data, events))
except Exception as e:
@ -3603,6 +3806,40 @@ async def streaming_chat_response_handler(response, ctx):
elif data.get('type', '').startswith('response.'):
output, response_metadata = handle_responses_streaming_event(data, output)
# Emit citation sources from finalized output items
# (mirrors Chat Completions annotation handling at delta level)
if data.get('type') == 'response.output_item.done':
item = data.get('item', {})
if item.get('type') == 'message':
for part in item.get('content', []):
for annotation in part.get('annotations', []):
if annotation.get('type') == 'url_citation':
# Handle both flat (Responses API) and nested (Chat Completions) formats
url_citation = annotation.get('url_citation', annotation)
url = url_citation.get('url', '')
title = url_citation.get('title', url)
if url:
await event_emitter(
{
'type': 'source',
'data': {
'source': {
'name': title,
'url': url,
},
'document': [title],
'metadata': [
{
'source': url,
'name': title,
}
],
},
}
)
processed_data = {
'output': full_output(),
'content': serialize_output(full_output()),
@ -4673,7 +4910,7 @@ async def streaming_chat_response_handler(response, ctx):
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}',
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
@ -4690,27 +4927,41 @@ async def streaming_chat_response_handler(response, ctx):
)
await background_tasks_handler(ctx)
ctx['assistant_message'] = {
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
}
await outlet_filter_handler(ctx)
except asyncio.CancelledError:
log.warning('Task was cancelled!')
await event_emitter({'type': 'chat:tasks:cancel'})
try:
await asyncio.shield(event_emitter({'type': 'chat:tasks:cancel'}))
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
},
)
else:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
await asyncio.shield(
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'done': True,
'content': serialize_output(output),
'output': output,
},
)
)
else:
await asyncio.shield(
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{'done': True},
)
)
except Exception:
pass
raise # re-raise CancelledError for proper propagation
if response.background is not None:
await response.background()

View file

@ -148,19 +148,22 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
messages = []
pending_tool_calls = []
pending_content = []
pending_reasoning = ''
def flush_pending():
nonlocal pending_content, pending_tool_calls
if pending_content or pending_tool_calls:
nonlocal pending_content, pending_tool_calls, pending_reasoning
if pending_content or pending_tool_calls or pending_reasoning:
messages.append(
{
'role': 'assistant',
'content': '\n'.join(pending_content) if pending_content else '',
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
**({'reasoning_content': pending_reasoning} if pending_reasoning else {}),
}
)
pending_content = []
pending_tool_calls = []
pending_reasoning = ''
for item in output:
item_type = item.get('type', '')
@ -245,6 +248,10 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
start_tag = item.get('start_tag', '<think>')
end_tag = item.get('end_tag', '</think>')
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
# Preserve raw reasoning text as reasoning_content for
# providers that require it on assistant tool-call messages
# (e.g. Moonshot/Kimi K2.5).
pending_reasoning += reasoning_text
# else: skip reasoning blocks for normal LLM messages
elif item_type == 'open_webui:code_interpreter':
@ -906,10 +913,16 @@ async def cleanup_response(
):
if response:
if not response.closed:
await response.close()
# aiohttp 3.9+ made ClientResponse.close() synchronous (returns None).
# Older versions returned a coroutine. Handle both gracefully.
result = response.close()
if result is not None:
await result
if session:
if not session.closed:
await session.close()
result = session.close()
if result is not None:
await result
async def stream_wrapper(response, session, content_handler=None):

View file

@ -22,7 +22,7 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.access_control import has_access, has_base_model_access
from open_webui.config import (
@ -283,11 +283,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Pre-warm the function module cache once per unique function ID.
# This ensures each function's DB freshness check runs exactly once,
# not once per (model × function) pair.
for function_id in all_function_ids:
# Only attempt to load functions that actually exist in the local DB;
# imported/custom model configs may reference tools or filters the user
# hasn't installed, and trying to load those would cause persistent
# "Failed to load function module" log spam on every model refresh.
for function_id in functions_by_id:
try:
await get_function_module_from_cache(request, function_id)
except Exception as e:
log.info(f'Failed to load function module for {function_id}: {e}')
log.debug(f'Failed to load function module for {function_id}: {e}')
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
@ -404,6 +408,10 @@ async def check_model_access(user, model, db=None):
):
raise Exception('Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, db=db):
raise Exception('Model not found')
async def get_filtered_models(models, user, db=None):
# Filter out models that the user does not have access to

View file

@ -140,6 +140,41 @@ auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
# Conservative default when the provider omits both expires_in and expires_at.
# Matches the value recommended by Authlib's compliance_fix documentation.
DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
def _normalize_token_expiry(token: dict) -> dict:
"""Ensure a token dict always has a numeric ``expires_at``.
Resolution order:
1. If *expires_at* is already present and non-None, trust it.
2. Else if *expires_in* is present and non-None, compute *expires_at*.
3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a
warning so operators can identify providers that omit expiration.
Also stamps *issued_at* for auditing.
"""
token['issued_at'] = datetime.now().timestamp()
if token.get('expires_at') is not None:
token['expires_at'] = int(token['expires_at'])
return token
if token.get('expires_in') is not None:
token['expires_at'] = int(datetime.now().timestamp() + token['expires_in'])
return token
# Neither field present — conservative fallback
log.warning(
"OAuth token response missing both 'expires_in' and 'expires_at'; "
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
)
token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
return token
FERNET = None
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
@ -478,10 +513,12 @@ async def get_oauth_client_info_with_static_credentials(
log.error(f'Error parsing OAuth metadata from {url}: {e}')
continue
# Determine scope from server metadata if available
# Let the OAuth provider apply its default scopes.
# We intentionally do NOT join all scopes_supported here — that list
# represents every scope the server *can* grant, not what the client
# should request. Requesting all of them is almost always wrong and
# can break providers like Entra ID that require resource-specific scopes.
scope = None
if oauth_server_metadata and oauth_server_metadata.scopes_supported:
scope = ' '.join(oauth_server_metadata.scopes_supported)
# Determine token_endpoint_auth_method
token_endpoint_auth_method = 'client_secret_post'
@ -513,6 +550,24 @@ async def get_oauth_client_info_with_static_credentials(
raise e
def resolve_oauth_client_info(connection: dict) -> dict:
"""
Decrypt OAuth client info from a tool server connection config.
For oauth_2.1_static, overlays admin-provided credentials from
info.oauth_client_id and info.oauth_client_secret onto the blob.
"""
info = connection.get('info', {})
data = decrypt_data(info.get('oauth_client_info', ''))
if connection.get('auth_type') == 'oauth_2.1_static':
if info.get('oauth_client_id') and info.get('oauth_client_secret'):
data['client_id'] = info['oauth_client_id']
data['client_secret'] = info['oauth_client_secret']
return data
class OAuthClientManager:
def __init__(self, app):
self.oauth = OAuth()
@ -536,15 +591,20 @@ class OAuthClientManager:
'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None),
}
if oauth_client_info.server_metadata and oauth_client_info.server_metadata.code_challenge_methods_supported:
if (
isinstance(
oauth_client_info.server_metadata.code_challenge_methods_supported,
list,
)
and 'S256' in oauth_client_info.server_metadata.code_challenge_methods_supported
):
kwargs['code_challenge_method'] = 'S256'
# Default to S256 for OAuth 2.1 (PKCE is mandatory per RFC 9700)
kwargs['code_challenge_method'] = 'S256'
# Only remove PKCE if metadata explicitly excludes S256
if (
oauth_client_info.server_metadata
and oauth_client_info.server_metadata.code_challenge_methods_supported
and isinstance(
oauth_client_info.server_metadata.code_challenge_methods_supported,
list,
)
and 'S256' not in oauth_client_info.server_metadata.code_challenge_methods_supported
):
del kwargs['code_challenge_method']
self.clients[client_id] = {
'client': self.oauth.register(**kwargs),
@ -584,7 +644,7 @@ class OAuthClientManager:
continue
try:
oauth_client_info = decrypt_data(oauth_client_info)
oauth_client_info = resolve_oauth_client_info(connection)
return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client']
except Exception as e:
log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}')
@ -707,7 +767,11 @@ class OAuthClientManager:
log.warning(f'No OAuth session found for user {user_id}, client_id {client_id}')
return None
if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at):
if (
force_refresh
or session.expires_at is None
or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at)
):
log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}')
refreshed_token = await self._refresh_token(session)
if refreshed_token:
@ -818,14 +882,7 @@ class OAuthClientManager:
if 'refresh_token' not in new_token_data:
new_token_data['refresh_token'] = token_data['refresh_token']
# Add timestamp for tracking
new_token_data['issued_at'] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if 'expires_in' in new_token_data and 'expires_at' not in new_token_data:
new_token_data['expires_at'] = int(
datetime.now().timestamp() + new_token_data['expires_in']
)
_normalize_token_expiry(new_token_data)
log.debug(f'Token refresh successful for client_id {client_id}')
return new_token_data
@ -878,12 +935,7 @@ class OAuthClientManager:
if token:
try:
# Add timestamp for tracking
token['issued_at'] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if 'expires_in' in token and 'expires_at' not in token:
token['expires_at'] = datetime.now().timestamp() + token['expires_in']
_normalize_token_expiry(token)
# Clean up any existing sessions for this user/client_id first
sessions = await OAuthSessions.get_sessions_by_user_id(user_id)
@ -970,7 +1022,11 @@ class OAuthManager:
log.warning(f'No OAuth session found for user {user_id}, session {session_id}')
return None
if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at):
if (
force_refresh
or session.expires_at is None
or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at)
):
log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}')
refreshed_token = await self._refresh_token(session)
if refreshed_token:
@ -1084,14 +1140,7 @@ class OAuthManager:
if 'refresh_token' not in new_token_data:
new_token_data['refresh_token'] = token_data['refresh_token']
# Add timestamp for tracking
new_token_data['issued_at'] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if 'expires_in' in new_token_data and 'expires_at' not in new_token_data:
new_token_data['expires_at'] = int(
datetime.now().timestamp() + new_token_data['expires_in']
)
_normalize_token_expiry(new_token_data)
log.debug(f'Token refresh successful for provider {provider}')
return new_token_data
@ -1689,12 +1738,7 @@ class OAuthManager:
)
try:
# Add timestamp for tracking
token['issued_at'] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if 'expires_in' in token and 'expires_at' not in token:
token['expires_at'] = datetime.now().timestamp() + token['expires_in']
_normalize_token_expiry(token)
# Enforce max concurrent sessions per user/provider to prevent
# unbounded growth while allowing multi-device usage

View file

@ -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)

View file

@ -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):

View file

@ -13,15 +13,15 @@ cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.11.0
authlib==1.6.9
authlib==1.6.10
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
requests==2.33.1
aiohttp==3.13.5 # do not update to 3.13.3 - broken
async-timeout
aiocache
aiofiles
starlette-compress==1.7.0
Brotli==1.1.0
Brotli==1.2.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
@ -52,7 +52,7 @@ langchain-text-splitters==1.1.1
fake-useragent==2.2.0
chromadb==1.5.2
black==26.1.0
black==26.3.1
pydub
chardet==5.2.0
beautifulsoup4

View file

@ -10,15 +10,15 @@ cryptography==46.0.5
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.11.0
authlib==1.6.9
authlib==1.6.10
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
requests==2.33.1
aiohttp==3.13.5 # do not update to 3.13.3 - broken
async-timeout==5.0.1
aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.0
Brotli==1.1.0
Brotli==1.2.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
@ -58,8 +58,8 @@ chromadb==1.5.2
weaviate-client==4.20.3
opensearch-py==3.1.0
transformers==5.3.0
sentence-transformers==5.2.3
transformers==5.5.4
sentence-transformers==5.4.0
accelerate==1.13.0
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.2
@ -95,7 +95,7 @@ rank-bm25==0.2.2
onnxruntime==1.24.3
faster-whisper==1.2.1
black==26.1.0
black==26.3.1
youtube-transcript-api==1.2.4
pytube==15.0.0

View file

@ -1,88 +0,0 @@
# Contributing to Open WebUI
🚀 **Welcome, Contributors!** 🚀
Your interest in contributing to Open WebUI is greatly appreciated. This document is here to guide you through the process, ensuring your contributions enhance the project effectively. Let's make Open WebUI even better, together!
## 📌 Key Points
### 🦙 Ollama vs. Open WebUI
It's crucial to distinguish between Ollama and Open WebUI:
- **Open WebUI** focuses on providing an intuitive and responsive web interface for chat interactions.
- **Ollama** is the underlying technology that powers these interactions.
If your issue or contribution pertains directly to the core Ollama technology, please direct it to the appropriate [Ollama project repository](https://ollama.com/). Open WebUI's repository is dedicated to the web interface aspect only.
### 🚨 Reporting Issues
Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently.
> [!IMPORTANT]
>
> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking.
> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue.
> [!WARNING]
> Reporting vulnerabilities is not wanted through Issues!
> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements.
### 🧭 Scope of Support
We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience.
- **Docker Deployment Support**: Open WebUI supports Docker deployment. Familiarity with Docker is assumed. For Docker basics, please refer to the [official Docker documentation](https://docs.docker.com/get-started/overview/).
- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects.
- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
## 💡 Contributing
Looking to contribute? Great! Here's how you can help:
### 🛠 Pull Requests
We welcome pull requests. Before submitting one, please:
1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose).
2. Follow the project's coding standards and include tests for new features.
3. Update documentation as necessary.
4. Write clear, descriptive commit messages.
5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward.
> [!NOTE]
> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!).
### 📚 Documentation & Tutorials
Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI.
Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
We welcome tutorials, guides and other documentation improvements!
### 🌐 Translations and Internationalization
Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project.
We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language.
To add a new language:
- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`.
- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object.
- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`.
> [!NOTE]
> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow.
### 🤔 Questions & Feedback
Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help!
## 🙏 Thank You!
Your contributions, big or small, make a significant impact on Open WebUI. We're excited to see what you bring to the project!
Together, let's create an even more powerful tool for the community. 🌟

View file

@ -1,3 +0,0 @@
# Project workflow
[![](https://mermaid.ink/img/pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg?type=png)](https://mermaid.live/edit#pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg)

View file

@ -1,205 +0,0 @@
# Hosting UI and Models separately
Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
# Open WebUI Configuration
## UI Configuration
For the UI configuration, you can set up the Apache VirtualHost as follows:
```
# Assuming you have a website hosting this UI at "server.com"
<VirtualHost 192.168.1.100:80>
ServerName server.com
DocumentRoot /home/server/public_html
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
</VirtualHost>
```
Enable the site first before you can request SSL:
`a2ensite server.com.conf` # this will enable the site. a2ensite is short for "Apache 2 Enable Site"
```
# For SSL
<VirtualHost 192.168.1.100:443>
ServerName server.com
DocumentRoot /home/server/public_html
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
SSLCertificateKeyFile /etc/ssl/virtualmin/170514456861234/ssl.key
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
SSLProxyEngine on
SSLCACertificateFile /etc/ssl/virtualmin/170514456865864/ssl.ca
</VirtualHost>
```
I'm using virtualmin here for my SSL clusters, but you can also use certbot directly or your preferred SSL method. To use SSL:
### Prerequisites.
Run the following commands:
`snap install certbot --classic`
`snap apt install python3-certbot-apache` (this will install the apache plugin).
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
Create server.com.conf if it is not yet already created, containing the above `<virtualhost>` configuration (it should match your case. Modify as necessary). Use the one without the SSL:
Once it's created, run `certbot --apache -d server.com`, this will request and add/create an SSL keys for you as well as create the server.com.le-ssl.conf
# Configuring Ollama Server
On your latest installation of Ollama, make sure that you have setup your api server from the official Ollama reference:
[Ollama FAQ](https://github.com/jmorganca/ollama/blob/main/docs/faq.md)
### TL;DR
The guide doesn't seem to match the current updated service file on linux. So, we will address it here:
Unless when you're compiling Ollama from source, installing with the standard install `curl https://ollama.com/install.sh | sh` creates a file called `ollama.service` in /etc/systemd/system. You can use nano to edit the file:
```
sudo nano /etc/systemd/system/ollama.service
```
Add the following lines:
```
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify
```
For instance:
```
[Unit]
Description=Ollama Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify 192.168.254.109:DIFFERENT_PORT, format
Environment="OLLAMA_ORIGINS=http://192.168.254.106:11434,https://models.server.city" # this line is optional
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/s>
[Install]
WantedBy=default.target
```
Save the file by pressing CTRL+S, then press CTRL+X
When your computer restarts, the Ollama server will now be listening on the IP:PORT you specified, in this case 0.0.0.0:11434, or 192.168.254.106:11434 (whatever your local IP address is). Make sure that your router is correctly configured to serve pages from that local IP by forwarding 11434 to your local IP server.
# Ollama Model Configuration
## For the Ollama model configuration, use the following Apache VirtualHost setup:
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
`nano models.server.city.conf` # match this with your ollama server domain
Add the following virtualhost containing this example (modify as needed):
```
# Assuming you have a website hosting this UI at "models.server.city"
<IfModule mod_ssl.c>
<VirtualHost 192.168.254.109:443>
DocumentRoot "/var/www/html/"
ServerName models.server.city
<Directory "/var/www/html/">
Options None
Require all granted
</Directory>
ProxyRequests Off
ProxyPreserveHost On
ProxyAddHeaders On
SSLProxyEngine on
ProxyPass / http://server.city:1000/ nocanon # or port 11434
ProxyPassReverse / http://server.city:1000/ # or port 11434
SSLCertificateFile /etc/letsencrypt/live/models.server.city/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/models.server.city/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>
```
You may need to enable the site first (if you haven't done so yet) before you can request SSL:
`a2ensite models.server.city.conf`
#### For the SSL part of Ollama server
Run the following commands:
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
`certbot --apache -d server.com`
```
<VirtualHost 192.168.254.109:80>
DocumentRoot "/var/www/html/"
ServerName models.server.city
<Directory "/var/www/html/">
Options None
Require all granted
</Directory>
ProxyRequests Off
ProxyPreserveHost On
ProxyAddHeaders On
SSLProxyEngine on
ProxyPass / http://server.city:1000/ nocanon # or port 11434
ProxyPassReverse / http://server.city:1000/ # or port 11434
RewriteEngine on
RewriteCond %{SERVER_NAME} =models.server.city
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
```
Don't forget to restart/reload Apache with `systemctl reload apache2`
Open your site at https://server.com!
**Congratulations**, your _**Open-AI-like Chat-GPT style UI**_ is now serving AI with RAG, RBAC and multimodal features! Download Ollama models if you haven't yet done so!
If you encounter any misconfiguration or errors, please file an issue or engage with our discussion. There are a lot of friendly developers here to assist you.
Let's make this UI much more user friendly for everyone!
Thanks for making open-webui your UI Choice for AI!
This doc is made by **Bob Reyes**, your **Open-WebUI** fan from the Philippines.

211
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.0",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -54,6 +54,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",
@ -214,42 +215,40 @@
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
"integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.1.2",
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
"@chevrotain/gast": "12.0.0",
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
"integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
"integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
"license": "Apache-2.0"
},
"node_modules/@codemirror/autocomplete": {
@ -1247,9 +1246,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1373,9 +1372,9 @@
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3583,9 +3582,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"version": "2.57.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@ -3611,7 +3610,7 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@ -5389,9 +5388,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -5619,9 +5618,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -5963,9 +5962,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6368,29 +6367,31 @@
}
},
"node_modules/chevrotain": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.1.2",
"@chevrotain/gast": "11.1.2",
"@chevrotain/regexp-to-ast": "11.1.2",
"@chevrotain/types": "11.1.2",
"@chevrotain/utils": "11.1.2",
"lodash-es": "4.17.23"
"@chevrotain/cst-dts-gen": "12.0.0",
"@chevrotain/gast": "12.0.0",
"@chevrotain/regexp-to-ast": "12.0.0",
"@chevrotain/types": "12.0.0",
"@chevrotain/utils": "12.0.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
"chevrotain": "^12.0.0"
}
},
"node_modules/chokidar": {
@ -7743,9 +7744,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -8238,9 +8239,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8462,7 +8463,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-fifo": {
@ -9051,9 +9051,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -10152,13 +10152,14 @@
}
},
"node_modules/langium": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
"integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.1.1",
"chevrotain-allstar": "~0.3.1",
"@chevrotain/regexp-to-ast": "~12.0.0",
"chevrotain": "~12.0.0",
"chevrotain-allstar": "~0.4.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.1.0"
@ -10600,16 +10601,16 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@ -10870,9 +10871,9 @@
"license": "MIT"
},
"node_modules/matcher-collection/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11103,9 +11104,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11765,9 +11766,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -11914,9 +11915,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@ -12326,9 +12327,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -12460,9 +12461,9 @@
"license": "MIT"
},
"node_modules/quick-temp/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12742,9 +12743,9 @@
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -15408,9 +15409,9 @@
}
},
"node_modules/vite-plugin-static-copy/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -16152,9 +16153,9 @@
"license": "MIT"
},
"node_modules/walk-sync/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -16441,9 +16442,9 @@
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.0",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@ -98,6 +98,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",

View file

@ -18,10 +18,10 @@ dependencies = [
"bcrypt==5.0.0",
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.11.0",
"authlib==1.6.9",
"authlib==1.6.10",
"requests==2.32.5",
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
"requests==2.33.1",
"aiohttp==3.13.5", # do not update to 3.13.3 - broken
"async-timeout==5.0.1",
"aiocache==0.12.3",
"aiofiles==25.1.0",
@ -64,8 +64,8 @@ dependencies = [
"PyMySQL==1.1.2",
"boto3==1.42.62",
"transformers==5.3.0",
"sentence-transformers==5.2.3",
"transformers==5.5.4",
"sentence-transformers==5.4.0",
"accelerate==1.13.0",
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
"einops==0.8.2",
@ -100,7 +100,7 @@ dependencies = [
"onnxruntime==1.24.3",
"faster-whisper==1.2.1",
"black==26.1.0",
"black==26.3.1",
"youtube-transcript-api==1.2.4",
"pytube==15.0.0",

View file

@ -260,6 +260,12 @@ select {
display: none;
}
/* Hide leaked Mermaid temp containers if render cleanup misses */
body > div[id^='dmermaid-'],
body > iframe[id^='imermaid-'] {
display: none !important;
}
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {

View file

@ -953,6 +953,73 @@ export const deleteSharedChatById = async (token: string, id: string) => {
return res;
};
export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
access_grants: accessGrants
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChatAccessGrants = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChatById = async (token: string, id: string, chat: object) => {
let error = null;

View file

@ -313,3 +313,65 @@ export const deleteNoteById = async (token: string, id: string) => {
return res;
};
export const getPinnedNoteList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/pinned`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res ?? [];
};
export const toggleNotePinnedStatusById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pin`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -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;

View file

@ -1185,6 +1185,23 @@
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Reranking Batch Size')}
</div>
<div class="">
<input
bind:value={RAGConfig.RAG_RERANKING_BATCH_SIZE}
type="number"
class=" bg-transparent text-center w-14 outline-none"
min="1"
max="16000"
step="1"
/>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
<div class="flex items-center relative">

View file

@ -1,9 +1,10 @@
<script lang="ts">
import type i18nType from '$lib/i18n';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
const i18n = getContext('i18n');
const i18n: typeof i18nType = getContext('i18n');
export let frequency = 'DAILY';
export let interval = 1;
@ -23,23 +24,23 @@
let showDropdown = false;
const FREQUENCIES = [
{ key: 'ONCE', label: 'Once' },
{ key: 'HOURLY', label: 'Hourly' },
{ key: 'DAILY', label: 'Daily' },
{ key: 'WEEKLY', label: 'Weekly' },
{ key: 'MONTHLY', label: 'Monthly' },
{ key: 'CUSTOM', label: 'Custom' }
$: FREQUENCIES = [
{ key: 'ONCE', label: $i18n.t('Once') },
{ key: 'HOURLY', label: $i18n.t('Hourly') },
{ key: 'DAILY', label: $i18n.t('Daily') },
{ key: 'WEEKLY', label: $i18n.t('Weekly') },
{ key: 'MONTHLY', label: $i18n.t('Monthly') },
{ key: 'CUSTOM', label: $i18n.t('Custom') }
];
const DAYS = [
{ key: 'MO', label: 'Mo' },
{ key: 'TU', label: 'Tu' },
{ key: 'WE', label: 'We' },
{ key: 'TH', label: 'Th' },
{ key: 'FR', label: 'Fr' },
{ key: 'SA', label: 'Sa' },
{ key: 'SU', label: 'Su' }
$: DAYS = [
{ key: 'MO', label: $i18n.t('Mo', { context: 'day_of_week' }) },
{ key: 'TU', label: $i18n.t('Tu', { context: 'day_of_week' }) },
{ key: 'WE', label: $i18n.t('We', { context: 'day_of_week' }) },
{ key: 'TH', label: $i18n.t('Th', { context: 'day_of_week' }) },
{ key: 'FR', label: $i18n.t('Fr', { context: 'day_of_week' }) },
{ key: 'SA', label: $i18n.t('Sa', { context: 'day_of_week' }) },
{ key: 'SU', label: $i18n.t('Su', { context: 'day_of_week' }) }
];
let lastVisualFrequency = 'DAILY';

View file

@ -51,6 +51,7 @@
type="button"
on:click={() => {
uploadFilesHandler();
show = false;
}}
>
<Clip />
@ -62,6 +63,7 @@
type="button"
on:click={() => {
screenCaptureHandler();
show = false;
}}
>
<Camera />

View file

@ -81,7 +81,6 @@
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import {
chatCompleted,
generateQueries,
chatAction,
generateMoACompletion,
@ -181,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 = '';
@ -491,6 +496,23 @@
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;
return; // Patches history.messages directly; skip the trailing write-back.
} else if (type === 'chat:message:favorite') {
// Update message favorite status
message.favorite = data.favorite;
@ -1361,6 +1383,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 +1449,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;
};
@ -1888,13 +1862,15 @@
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();
await sendMessage(history, userMessageId, { newChat: true });
await sendMessage(history, userMessageId);
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
@ -1994,13 +1970,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 +1993,8 @@
: selectedModels;
// Create response messages for each selected model
// Build message_ids map: {model_id: assistant_message_id}
const messageIdsMap: Record<string, string> = {};
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
@ -2030,6 +2006,7 @@
childrenIds: [],
role: 'assistant',
content: '',
done: false,
model: model.id,
modelName: model.name ?? model.id,
modelIdx: modelIdx ? modelIdx : _modelIdx,
@ -2042,7 +2019,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
@ -2050,68 +2026,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 = () => {
@ -2166,7 +2145,14 @@
.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<string, string>
) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
@ -2217,53 +2203,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 = [];
@ -2320,12 +2307,15 @@
// Use the user-selected terminal from the dropdown
const activeTerminalId = $selectedTerminalId ?? null;
// Only send terminal_id if the model has terminal capability enabled
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
stream: stream,
model: model.id,
messages: messages,
...(messages.length > 0 ? { messages } : {}),
params: {
...$settings?.params,
...params,
@ -2337,7 +2327,7 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: activeTerminalId ?? undefined,
terminal_id: terminalEnabled ? (activeTerminalId ?? undefined) : undefined,
tool_servers: [
...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
@ -2356,20 +2346,16 @@
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 &&
(messages.length == 1 ||
(messages.length == 2 &&
messages.at(0)?.role === 'system' &&
messages.at(1)?.role === 'user')) &&
(selectedModels[0] === model.id || atSelectedModel !== undefined)
...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null
? {
title_generation: $settings?.title?.auto ?? true,
tags_generation: $settings?.autoTags ?? true
@ -2418,10 +2404,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));
}
}
}
}
@ -3013,7 +3011,7 @@
if (e.detail || files.length > 0) {
await tick();
submitHandler(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail);
}
}}
/>
@ -3056,7 +3054,7 @@
clearDraft();
if (e.detail || files.length > 0) {
await tick();
submitHandler(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail);
}
}}
/>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
@ -8,6 +9,7 @@
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Pencil from '../../icons/Pencil.svelte';
import Clipboard from '../../icons/Clipboard.svelte';
const i18n = getContext('i18n');
@ -315,6 +317,24 @@
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
navigator.clipboard.writeText(path).then(() => {
toast.success($i18n.t('Path copied'));
});
}}
>
<Clipboard className="size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
</button>
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"

View file

@ -343,7 +343,9 @@
}
chatInputElement?.setText(text);
chatInputElement?.focus();
if (!$showCallOverlay) {
chatInputElement?.focus();
}
if (text !== '') {
text = await inputVariableHandler(text);
@ -493,6 +495,11 @@
$models.find((m) => m.id === model)?.info?.meta?.capabilities?.code_interpreter ?? true
);
let terminalCapableModels = [];
$: terminalCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true
);
let toggleFilters = [];
$: toggleFilters = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels)
.map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? [])
@ -528,6 +535,11 @@
codeInterpreterEnabled = false;
}
// Clear selected terminal when model doesn't support terminal
$: if ($selectedTerminalId && terminalCapableModels.length === 0) {
selectedTerminalId.set(null);
}
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
element.scrollTo({
@ -1745,7 +1757,19 @@
<Tooltip content={filter?.name} placement="top">
<button
on:click|preventDefault={() => {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filterId);
if (
filter?.has_user_valves &&
($_user?.role === 'admin' ||
($_user?.permissions?.chat?.valves ?? true))
) {
selectedValvesType = 'function';
selectedValvesItemId = filterId;
showValvesModal = true;
} else {
selectedFilterIds = selectedFilterIds.filter(
(id) => id !== filterId
);
}
}}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
@ -1768,7 +1792,18 @@
{:else}
<Sparkles className="size-4" strokeWidth="1.75" />
{/if}
<div class="hidden group-hover:block">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="hidden group-hover:block"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFilterIds = selectedFilterIds.filter(
(id) => id !== filterId
);
}}
>
<XMark className="size-4" strokeWidth="1.75" />
</div>
</button>
@ -1906,7 +1941,7 @@
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<!-- Terminal Server Selector -->
{#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)}
{#if terminalCapableModels.length > 0 && (($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url))}
<TerminalMenu bind:show={showTerminalMenu} />
{/if}

View file

@ -57,18 +57,124 @@
export let onSelect = (e) => {};
export let messagesCount: number | null = 20;
export let messagesCount: number | null = 8;
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 += 8;
buildMessages();
// Show all messages during progressive loading (no culling)
visibleStart = 0;
visibleEnd = messages.length;
topSpacerHeight = 0;
bottomSpacerHeight = 0;
await tick();
messagesLoading = false;
@ -95,6 +201,7 @@
}
messages = _messages.reverse();
visibleEnd = messages.length;
};
// Throttle message list rebuilds to once per animation frame during streaming.
@ -113,6 +220,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,10 +535,6 @@
showMessage({ id: parentMessageId }, false);
};
onDestroy(() => {
cancelAnimationFrame(pendingRebuild);
});
const triggerScroll = () => {
if (autoScroll) {
const element = document.getElementById('messages-container');
@ -465,7 +570,13 @@
</Loader>
{/if}
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
{#each messages as message, messageIdx (message.id)}
<!-- Top spacer: sum of cached heights for messages above visible range -->
{#if topSpacerHeight > 0}
<div style="height: {topSpacerHeight}px" aria-hidden="true" />
{/if}
{#each messages.slice(visibleStart, visibleEnd) as message, i (message.id)}
{@const messageIdx = visibleStart + i}
<Message
{chatId}
bind:history
@ -494,6 +605,11 @@
{topPadding}
/>
{/each}
<!-- Bottom spacer: sum of cached heights for messages below visible range -->
{#if bottomSpacerHeight > 0}
<div style="height: {bottomSpacerHeight}px" aria-hidden="true" />
{/if}
</ul>
</section>
<div class="pb-18" />

View file

@ -380,6 +380,7 @@
<ToolCallDisplay
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
attributes={detailToken.attributes}
resultContent={getDetailTextContent(detailToken)}
grouped={true}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
@ -428,6 +429,7 @@
<ToolCallDisplay
id={`${id}-${tokenIdx}-tc`}
attributes={token.attributes}
resultContent={getDetailTextContent(token)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
/>

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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;
}
</script>

View file

@ -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);
}
}

View file

@ -450,6 +450,7 @@
});
if (res) {
// $i18n.t('Model {{modelId}} not found')
toast.success(
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
);

View file

@ -248,7 +248,7 @@
<hr class="border-gray-50 dark:border-gray-850/30 my-4" />
{#if $config?.features.enable_login_form}
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
<div class="mt-2">
<UpdatePassword />
</div>

View file

@ -3,24 +3,32 @@
import { models, config } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';
import {
deleteSharedChatById,
getChatById,
shareChatById,
getChatAccessGrants,
updateChatAccessGrants
} from '$lib/apis/chats';
import { copyToClipboard } from '$lib/utils';
import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
export let chatId;
let chat = null;
let shareUrl = null;
let accessGrants: any[] = [];
const i18n = getContext('i18n');
const shareLocalChat = async () => {
const _chat = chat;
const sharedChat = await shareChatById(localStorage.token, chatId);
shareUrl = `${window.location.origin}/s/${sharedChat.id}`;
shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`;
console.log(shareUrl);
chat = await getChatById(localStorage.token, chatId);
@ -54,6 +62,25 @@
);
};
const loadAccessGrants = async () => {
if (!chatId) return;
try {
accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? [];
} catch (e) {
console.error('Failed to load access grants', e);
accessGrants = [];
}
};
const saveAccessGrants = async () => {
try {
await updateChatAccessGrants(localStorage.token, chatId, accessGrants);
toast.success($i18n.t('Access updated'));
} catch (e) {
toast.error(`${e}`);
}
};
export let show = false;
const isDifferentChat = (_chat) => {
@ -73,8 +100,10 @@
if (isDifferentChat(_chat)) {
chat = _chat;
}
await loadAccessGrants();
} else {
chat = null;
accessGrants = [];
console.log(chat);
}
})();
@ -97,8 +126,8 @@
</div>
{#if chat}
<div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center">
<div class=" text-sm dark:text-gray-300 mb-1">
<div class="px-5 pt-4 pb-5 w-full flex flex-col">
<div class="text-sm dark:text-gray-300">
{#if chat.share_id}
<a href="/s/{chat.share_id}" target="_blank"
>{$i18n.t('You have shared this chat')}
@ -124,70 +153,69 @@
{/if}
</div>
<div class="flex justify-end">
<div class="flex flex-col items-end space-x-1 mt-3">
<div class="flex gap-1">
{#if $config?.features.enable_community_sharing}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
show = false;
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Oh, Safari, you're so special, let's give you some extra love and attention
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
show = false;
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
{#if chat.share_id}
<div class="mt-3">
<AccessControl bind:accessGrants accessRoles={['read']} onChange={saveAccessGrants} />
</div>
{/if}
<div class="flex justify-end gap-1 mt-3">
{#if $config?.features.enable_community_sharing}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
</div>
{/if}

View file

@ -41,6 +41,7 @@
}
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
show = false;
if (modalElement) {
if (document.body.contains(modalElement)) {

View file

@ -36,6 +36,18 @@
});
turndownService.escape = (string) => string;
// Produce single newlines between paragraphs instead of double.
// TipTap wraps every line in <p> tags; the default Turndown rule emits
// \n\n around each paragraph which then required a destructive
// replaceAll('\n\n','\n') that also wiped blank lines inside code blocks.
// This rule eliminates that hack so <pre><code> content is untouched.
turndownService.addRule('singleNewlineParagraphs', {
filter: 'p',
replacement: function (content) {
return '\n' + content + '\n';
}
});
// Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm);
@ -435,7 +447,6 @@
export const setText = (text: string) => {
if (!editor || !editor.view) return;
text = text.replaceAll('\n\n', '\n');
if (text === '') {
editor.commands.clearContent();

View file

@ -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';

View file

@ -16,6 +16,7 @@
mobile,
showArchivedChats,
pinnedChats,
pinnedNotes,
scrollPaginationEnabled,
currentChatPage,
temporaryChatEnabled,
@ -44,6 +45,8 @@
} from '$lib/apis/chats';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { checkActiveChats } from '$lib/apis/tasks';
import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
import { createNoteHandler } from '$lib/components/notes/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
@ -86,6 +89,7 @@
let pinnedModels = [];
let showPinnedModels = false;
let showPinnedNotes = false;
let showChannels = false;
let showFolders = false;
@ -227,6 +231,16 @@
const _pinnedChats = await getPinnedChatList(localStorage.token);
pinnedChats.set(_pinnedChats);
})(),
await (async () => {
if (
$config?.features?.enable_notes &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
) {
console.log('Init pinned notes');
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
pinnedNotes.set(_pinnedNotes);
}
})(),
await (async () => {
console.log('Init chat list');
const _chats = await getChatList(localStorage.token, $currentChatPage);
@ -1072,6 +1086,70 @@
</Folder>
{/if}
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
<Folder
id="sidebar-pinned-notes"
bind:open={showPinnedNotes}
className="px-2 mt-0.5"
name={$i18n.t('Notes')}
chevron={false}
dragAndDrop={false}
onAdd={async () => {
const note = await createNoteHandler('New Note');
if (note) {
goto(`/notes/${note.id}`);
}
}}
onAddLabel={$i18n.t('New Note')}
>
<div class="mt-0.5 pb-1.5">
{#each $pinnedNotes as note (note.id)}
<a
class="w-full flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition group text-sm"
href={`/notes/${note.id}`}
on:click={() => {
itemClickHandler();
}}
draggable="false"
>
<div class="self-center">
<Note className="size-4" strokeWidth="2" />
</div>
<div class="flex-1 text-ellipsis line-clamp-1">
{note.title}
</div>
<button
class="invisible group-hover:visible self-center p-0.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg transition"
on:click|preventDefault|stopPropagation={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(
() => []
);
pinnedNotes.set(_pinnedNotes);
}}
aria-label={$i18n.t('Unpin')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
</button>
</a>
{/each}
</div>
</Folder>
{/if}
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
<Folder
id="sidebar-channels"

View file

@ -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) {

View file

@ -35,7 +35,8 @@
showSidebar,
socket,
user,
WEBUI_NAME
WEBUI_NAME,
pinnedNotes
} from '$lib/stores';
import { downloadPdf } from './utils';
@ -64,7 +65,9 @@
deleteNoteById,
getNoteById,
updateNoteById,
updateNoteAccessGrants
updateNoteAccessGrants,
toggleNotePinnedStatusById,
getPinnedNoteList
} from '$lib/apis/notes';
import RichTextInput from '../common/RichTextInput.svelte';
@ -1088,6 +1091,12 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
onDelete={() => {
showDeleteConfirm = true;
}}
isPinned={note.is_pinned ?? false}
onPin={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
note = await getNoteById(localStorage.token, note.id);
pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => []));
}}
>
<div class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg">
<EllipsisHorizontal className="size-5" />

View file

@ -169,13 +169,18 @@ Based on the user's instruction, update and enhance the existing notes or select
: '') +
(selectedContent ? `\n<selection>${selectedContent?.text}</selection>` : '');
// 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
])
);

View file

@ -30,13 +30,15 @@
$: loadLocale($i18n.languages);
import { goto } from '$app/navigation';
import { WEBUI_NAME, config, user } from '$lib/stores';
import { WEBUI_NAME, config, user, pinnedNotes } from '$lib/stores';
import {
createNewNote,
deleteNoteById,
getNoteById,
getNoteList,
searchNotes
searchNotes,
toggleNotePinnedStatusById,
getPinnedNoteList
} from '$lib/apis/notes';
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
import { downloadPdf, createNoteHandler } from './utils';
@ -540,6 +542,14 @@
selectedNote = note;
showDeleteConfirm = true;
}}
isPinned={note.is_pinned ?? false}
onPin={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
pinnedNotes.set(
await getPinnedNoteList(localStorage.token).catch(() => [])
);
init();
}}
>
<button
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
@ -602,6 +612,14 @@
selectedNote = note;
showDeleteConfirm = true;
}}
isPinned={note.is_pinned ?? false}
onPin={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
pinnedNotes.set(
await getPinnedNoteList(localStorage.token).catch(() => [])
);
init();
}}
>
<button
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"

View file

@ -8,6 +8,8 @@
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Share from '$lib/components/icons/Share.svelte';
import Link from '$lib/components/icons/Link.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
const i18n = getContext('i18n');
@ -16,6 +18,8 @@
export let onDownload = (type) => {};
export let onDelete = () => {};
export let onPin = null;
export let isPinned = false;
export let onCopyLink = null;
export let onCopyToClipboard = null;
@ -110,6 +114,24 @@
</DropdownSub>
{/if}
{#if onPin}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={() => {
onPin();
show = false;
}}
>
{#if isPinned}
<PinSlash />
<div class="flex items-center">{$i18n.t('Unpin')}</div>
{:else}
<Pin />
<div class="flex items-center">{$i18n.t('Pin to Sidebar')}</div>
{/if}
</button>
{/if}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={() => {

View file

@ -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 @@
<div class="shrink-0 w-full flex justify-between items-center">
<div class="w-full">
<input
class="w-full text-3xl font-medium bg-transparent outline-hidden"
class="w-full text-3xl bg-transparent outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Title')}
@ -67,18 +66,16 @@
</div>
<div class=" flex-1 w-full h-full">
<RichTextInput
<textarea
class="w-full h-full min-h-[200px] bg-transparent outline-none resize-none text-base leading-relaxed placeholder:text-gray-300 dark:placeholder:text-gray-600"
bind:value={content}
placeholder={$i18n.t('Write something...')}
preserveBreaks={true}
/>
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-end text-sm font-medium shrink-0 mt-1 p-4 gap-1.5"
>
<div class="flex flex-row items-center justify-end text-sm shrink-0 mt-1 p-4 gap-1.5">
<div class="">
{#if voiceInput}
<div class=" max-w-full w-full">

View file

@ -56,15 +56,6 @@
const allTools = Object.keys(toolLabels);
export let builtinTools: Record<string, boolean> = {};
// Initialize missing keys to true (default enabled)
$: {
for (const tool of allTools) {
if (!(tool in builtinTools)) {
builtinTools[tool] = true;
}
}
}
</script>
<div>
@ -77,10 +68,12 @@
<Checkbox
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
on:change={(e) => {
builtinTools = {
...builtinTools,
[tool]: e.detail === 'checked'
};
if (e.detail === 'checked') {
delete builtinTools[tool];
} else {
builtinTools[tool] = false;
}
builtinTools = builtinTools;
}}
/>

View file

@ -31,6 +31,12 @@
label: $i18n.t('Code Interpreter'),
description: $i18n.t('Model can execute code and perform calculations')
},
terminal: {
label: $i18n.t('Terminal'),
description: $i18n.t(
'Model can access Open Terminal for command execution and file management'
)
},
usage: {
label: $i18n.t('Usage'),
description: $i18n.t(
@ -60,6 +66,7 @@
web_search?: boolean;
image_generation?: boolean;
code_interpreter?: boolean;
terminal?: boolean;
usage?: boolean;
citations?: boolean;
status_updates?: boolean;

View file

@ -839,9 +839,11 @@
</div>
{/if}
<div class="my-4">
<TerminalSelector bind:terminalId />
</div>
{#if capabilities.terminal}
<div class="my-4">
<TerminalSelector bind:terminalId />
</div>
{/if}
<div class="my-4">
<div class="flex w-full justify-between mb-1">

Some files were not shown because too many files have changed in this diff Show more