From 8cea0cf746da927fea358cf95f2813a53c66e1b4 Mon Sep 17 00:00:00 2001 From: _00_ <131402327+rgaricano@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:09:53 +0100 Subject: [PATCH 01/58] FIX: Pipeline save settings - Handle undefined valves property (#19791) ### FIX: Pipeline save settings - Handle undefined valves property When a Pipeline valve have a null value the settings isn't saved. The error occurs because the code tries to call `.split()` on a `null` value when saving pipeline valves. This happens when you set a valve to "None" (null) and then click save. This PR Fix this issue. --- src/lib/components/admin/Settings/Pipelines.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/admin/Settings/Pipelines.svelte b/src/lib/components/admin/Settings/Pipelines.svelte index 18446da7dd..81ecfe2218 100644 --- a/src/lib/components/admin/Settings/Pipelines.svelte +++ b/src/lib/components/admin/Settings/Pipelines.svelte @@ -47,7 +47,7 @@ if (pipeline && (pipeline?.valves ?? false)) { for (const property in valves_spec.properties) { if (valves_spec.properties[property]?.type === 'array') { - valves[property] = valves[property].split(',').map((v) => v.trim()); + valves[property] = (valves[property] ?? '').split(',').map((v) => v.trim()); } } From bcd50ed8f1b7387fd700538ae0d74fc72f3c53d0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 8 Dec 2025 11:30:38 -0500 Subject: [PATCH 02/58] refac --- backend/open_webui/utils/middleware.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 140d2bc85d..d397471dd9 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -716,17 +716,18 @@ async def chat_web_search_handler( return form_data -def get_last_images(message_list): +def get_images_from_messages(message_list): images = [] + for message in reversed(message_list): - images_flag = False + + message_images = [] for file in message.get("files", []): if file.get("type") == "image": - images.append(file.get("url")) - images_flag = True + message_images.append(file.get("url")) - if images_flag: - break + if message_images: + images.append(message_images) return images @@ -780,7 +781,16 @@ async def chat_image_generation_handler( user_message = get_last_user_message(message_list) prompt = user_message - input_images = get_last_images(message_list) + message_images = get_images_from_messages(message_list) + + # Limit to first 2 sets of images + # We may want to change this in the future to allow more images + input_images = [] + for idx, images in enumerate(message_images): + if idx >= 2: + break + for image in images: + input_images.append(image) system_message_content = "" From b02397e460ec56b4b74146508eeab2a3ba13950e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 8 Dec 2025 11:49:27 -0500 Subject: [PATCH 03/58] feat: WEB_LOADER_TIMEOUT --- backend/open_webui/config.py | 7 +++++++ backend/open_webui/main.py | 2 ++ backend/open_webui/retrieval/web/utils.py | 15 +++++++++++++++ backend/open_webui/routers/retrieval.py | 5 +++++ .../components/admin/Settings/WebSearch.svelte | 13 +++++++++++++ 5 files changed, 42 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 6d60ab89f5..983db4e04b 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -635,6 +635,7 @@ OAUTH_AUDIENCE = PersistentConfig( os.environ.get("OAUTH_AUDIENCE", ""), ) + def load_oauth_providers(): OAUTH_PROVIDERS.clear() if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value: @@ -2999,6 +3000,12 @@ WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig( int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")), ) +WEB_LOADER_TIMEOUT = PersistentConfig( + "WEB_LOADER_TIMEOUT", + "rag.web.loader.timeout", + os.getenv("WEB_LOADER_TIMEOUT", ""), +) + ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( "ENABLE_WEB_LOADER_SSL_VERIFICATION", diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index dc4468e8e7..5609289166 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -208,6 +208,7 @@ from open_webui.config import ( FIRECRAWL_API_KEY, WEB_LOADER_ENGINE, WEB_LOADER_CONCURRENT_REQUESTS, + WEB_LOADER_TIMEOUT, WHISPER_MODEL, WHISPER_VAD_FILTER, WHISPER_LANGUAGE, @@ -922,6 +923,7 @@ app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS +app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index bdbde0b3a9..ec0a8d8ed7 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -33,6 +33,7 @@ from open_webui.config import ( PLAYWRIGHT_WS_URL, PLAYWRIGHT_TIMEOUT, WEB_LOADER_ENGINE, + WEB_LOADER_TIMEOUT, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, TAVILY_API_KEY, @@ -674,6 +675,20 @@ def get_web_loader( if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web": WebLoaderClass = SafeWebBaseLoader + + request_kwargs = {} + if WEB_LOADER_TIMEOUT.value: + try: + timeout_value = float(WEB_LOADER_TIMEOUT.value) + except ValueError: + timeout_value = None + + if timeout_value: + request_kwargs["timeout"] = timeout_value + + if request_kwargs: + web_loader_args["requests_kwargs"] = request_kwargs + if WEB_LOADER_ENGINE.value == "playwright": WebLoaderClass = SafePlaywrightURLLoader web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index cc2457eba7..08ffde1733 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -536,6 +536,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, @@ -594,6 +595,7 @@ class WebConfig(BaseModel): SOUGOU_API_SID: Optional[str] = None SOUGOU_API_SK: Optional[str] = None WEB_LOADER_ENGINE: Optional[str] = None + WEB_LOADER_TIMEOUT: Optional[str] = None ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None PLAYWRIGHT_WS_URL: Optional[str] = None PLAYWRIGHT_TIMEOUT: Optional[int] = None @@ -1071,6 +1073,8 @@ async def update_rag_config( # Web loader settings request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE + request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT + request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ( form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION ) @@ -1206,6 +1210,7 @@ async def update_rag_config( "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index 17191ac216..f26a9f85b8 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -767,6 +767,19 @@ {#if webConfig.WEB_LOADER_ENGINE === '' || webConfig.WEB_LOADER_ENGINE === 'safe_web'} +
+
+ {$i18n.t('Timeout')} +
+
+ +
+
+
{$i18n.t('Verify SSL Certificate')} From ba158d378f18706cc85231504ede1610509cb7c4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 8 Dec 2025 11:59:45 -0500 Subject: [PATCH 04/58] feat: REDIS_SOCKET_CONNECT_TIMEOUT Co-Authored-By: Jan Kessler --- backend/open_webui/env.py | 16 +++++++++++++++- backend/open_webui/utils/redis.py | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index e3c50ea8d1..7d6ecfada0 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -395,6 +395,13 @@ try: except ValueError: REDIS_SENTINEL_MAX_RETRY_COUNT = 2 + +REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "") +try: + REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT) +except ValueError: + REDIS_SOCKET_CONNECT_TIMEOUT = None + #################################### # UVICORN WORKERS #################################### @@ -620,9 +627,16 @@ ENABLE_WEBSOCKET_SUPPORT = ( WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "") + + if WEBSOCKET_REDIS_OPTIONS == "": log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") - WEBSOCKET_REDIS_OPTIONS = None + if REDIS_SOCKET_CONNECT_TIMEOUT: + WEBSOCKET_REDIS_OPTIONS = { + "socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT + } + else: + WEBSOCKET_REDIS_OPTIONS = None else: try: WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS) diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index cc29ce6683..da6df2a7f9 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -7,6 +7,7 @@ import redis from open_webui.env import ( REDIS_CLUSTER, + REDIS_SOCKET_CONNECT_TIMEOUT, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_MAX_RETRY_COUNT, REDIS_SENTINEL_PORT, @@ -162,6 +163,7 @@ def get_redis_connection( username=redis_config["username"], password=redis_config["password"], decode_responses=decode_responses, + socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, @@ -188,6 +190,7 @@ def get_redis_connection( username=redis_config["username"], password=redis_config["password"], decode_responses=decode_responses, + socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, From f5fbbaf060e5a8e99aac3a9fcd4ae0c29483989a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 8 Dec 2025 12:18:25 -0500 Subject: [PATCH 05/58] refac: redis config log Co-Authored-By: Jan Kessler --- backend/open_webui/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 7d6ecfada0..d49a79b3b1 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -630,12 +630,12 @@ WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "") if WEBSOCKET_REDIS_OPTIONS == "": - log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") if REDIS_SOCKET_CONNECT_TIMEOUT: WEBSOCKET_REDIS_OPTIONS = { "socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT } else: + log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") WEBSOCKET_REDIS_OPTIONS = None else: try: From 2b1a29d44bde9fbc20ff9f0a5ded1ce8ded9d90d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 8 Dec 2025 12:45:52 -0500 Subject: [PATCH 06/58] enh: display user groups in user preview --- backend/open_webui/routers/users.py | 4 +++- .../channel/Messages/Message/UserStatus.svelte | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 3c1bbb72a8..dd2077206a 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -391,6 +391,7 @@ async def update_user_info_by_session_user( class UserActiveResponse(UserStatus): name: str profile_image_url: Optional[str] = None + groups: Optional[list] = [] is_active: bool model_config = ConfigDict(extra="allow") @@ -412,11 +413,12 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): ) user = Users.get_user_by_id(user_id) - if user: + groups = Groups.get_groups_by_member_id(user_id) return UserActiveResponse( **{ **user.model_dump(), + "groups": [{"id": group.id, "name": group.name} for group in groups], "is_active": Users.is_user_active(user_id), } ) diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte index 222f3441b5..cb40898898 100644 --- a/src/lib/components/channel/Messages/Message/UserStatus.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte @@ -102,6 +102,18 @@
{/if} + {#if (user?.groups ?? []).length > 0} +
+ {#each user.groups as group} +
+ {group.name} +
+ {/each} +
+ {/if} + {#if $_user?.id !== user.id}
From 44e41806f20fc483b499431844f280cec3f0cfa2 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:28:21 +0100 Subject: [PATCH 07/58] chore: dep bump across many dependencies (#19850) * Update pyproject.toml (#101) * Update pyproject.toml * Update requirements.txt * Update requirements-min.txt * Upgrade Playwright version to 1.57.0 * Update langchain-community version to 0.3.29 * Update requirements.txt * Update requirements-min.txt --- backend/requirements-min.txt | 16 +++--- backend/requirements.txt | 94 +++++++++++++++++----------------- docker-compose.playwright.yaml | 4 +- pyproject.toml | 75 ++++++++++++++------------- 4 files changed, 95 insertions(+), 94 deletions(-) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 714d44c1bd..bcf154279e 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -1,7 +1,7 @@ # Minimal requirements for backend to run # WIP: use this as a reference to build a minimal docker image -fastapi==0.123.0 +fastapi==0.124.0 uvicorn[standard]==0.37.0 pydantic==2.12.5 python-multipart==0.0.20 @@ -16,7 +16,7 @@ PyJWT[crypto]==2.10.1 authlib==1.6.5 requests==2.32.5 -aiohttp==3.12.15 +aiohttp==3.13.2 async-timeout aiocache aiofiles @@ -24,21 +24,21 @@ starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 -sqlalchemy==2.0.38 +sqlalchemy==2.0.44 alembic==1.17.2 peewee==3.18.3 peewee-migrate==1.14.3 -pycrdt==0.12.25 +pycrdt==0.12.44 redis -APScheduler==3.10.4 -RestrictedPython==8.0 +APScheduler==3.11.1 +RestrictedPython==8.1 loguru==0.7.3 asgiref==3.11.0 -mcp==1.22.0 +mcp==1.23.1 openai langchain==0.3.27 @@ -46,6 +46,6 @@ langchain-community==0.3.29 fake-useragent==2.2.0 chromadb==1.3.5 -black==25.11.0 +black==25.12.0 pydub chardet==5.2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index c9ccd9c28f..558b6ecc46 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.123.0 +fastapi==0.124.0 uvicorn[standard]==0.37.0 pydantic==2.12.5 python-multipart==0.0.20 @@ -13,7 +13,7 @@ PyJWT[crypto]==2.10.1 authlib==1.6.5 requests==2.32.5 -aiohttp==3.12.15 +aiohttp==3.13.2 async-timeout aiocache aiofiles @@ -21,27 +21,27 @@ starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 -sqlalchemy==2.0.38 +sqlalchemy==2.0.44 alembic==1.17.2 peewee==3.18.3 peewee-migrate==1.14.3 -pycrdt==0.12.25 +pycrdt==0.12.44 redis -APScheduler==3.10.4 -RestrictedPython==8.0 +APScheduler==3.11.1 +RestrictedPython==8.1 loguru==0.7.3 asgiref==3.11.0 # AI libraries tiktoken -mcp==1.22.0 +mcp==1.23.3 openai anthropic -google-genai==1.52.0 +google-genai==1.54.0 google-generativeai==0.8.5 langchain==0.3.27 @@ -49,8 +49,8 @@ langchain-community==0.3.29 fake-useragent==2.2.0 chromadb==1.3.5 -weaviate-client==4.17.0 -opensearch-py==2.8.0 +weaviate-client==4.18.3 +opensearch-py==3.1.0 transformers==4.57.3 sentence-transformers==5.1.2 @@ -60,43 +60,43 @@ einops==0.8.1 ftfy==6.3.1 chardet==5.2.0 -pypdf==6.4.0 -fpdf2==2.8.2 -pymdown-extensions==10.17.2 -docx2txt==0.8 +pypdf==6.4.1 +fpdf2==2.8.5 +pymdown-extensions==10.18 +docx2txt==0.9 python-pptx==1.0.2 unstructured==0.18.21 msoffcrypto-tool==5.4.2 -nltk==3.9.1 +nltk==3.9.2 Markdown==3.10 pypandoc==1.16.2 -pandas==2.2.3 +pandas==2.3.3 openpyxl==3.1.5 pyxlsb==1.0.10 -xlrd==2.0.1 +xlrd==2.0.2 validators==0.35.0 psutil sentencepiece soundfile==0.13.1 -pillow==11.3.0 -opencv-python-headless==4.11.0.86 +pillow==12.0.0 +opencv-python-headless==4.12.0.88 rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 -onnxruntime==1.20.1 -faster-whisper==1.1.1 +onnxruntime==1.23.2 +faster-whisper==1.2.1 -black==25.11.0 -youtube-transcript-api==1.2.2 +black==25.12.0 +youtube-transcript-api==1.2.3 pytube==15.0.0 pydub -ddgs==9.9.2 +ddgs==9.9.3 azure-ai-documentintelligence==1.0.2 -azure-identity==1.25.0 -azure-storage-blob==12.24.1 +azure-identity==1.25.1 +azure-storage-blob==12.27.1 azure-search-documents==11.6.0 ## Google Drive @@ -105,26 +105,26 @@ google-auth-httplib2 google-auth-oauthlib googleapis-common-protos==1.72.0 -google-cloud-storage==2.19.0 +google-cloud-storage==3.7.0 ## Databases pymongo -psycopg2-binary==2.9.10 -pgvector==0.4.1 +psycopg2-binary==2.9.11 +pgvector==0.4.2 -PyMySQL==1.1.1 -boto3==1.41.5 +PyMySQL==1.1.2 +boto3==1.42.5 pymilvus==2.6.5 qdrant-client==1.16.1 -playwright==1.56.0 # Caution: version must match docker-compose.playwright.yaml -elasticsearch==9.1.0 +playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary +elasticsearch==9.2.0 pinecone==6.0.2 -oracledb==3.2.0 +oracledb==3.4.1 av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720 -colbert-ai==0.2.21 +colbert-ai==0.2.22 ## Tests @@ -136,17 +136,17 @@ pytest-docker~=3.2.5 ldap3==2.9.1 ## Firecrawl -firecrawl-py==4.10.0 +firecrawl-py==4.10.4 ## Trace -opentelemetry-api==1.38.0 -opentelemetry-sdk==1.38.0 -opentelemetry-exporter-otlp==1.38.0 -opentelemetry-instrumentation==0.59b0 -opentelemetry-instrumentation-fastapi==0.59b0 -opentelemetry-instrumentation-sqlalchemy==0.59b0 -opentelemetry-instrumentation-redis==0.59b0 -opentelemetry-instrumentation-requests==0.59b0 -opentelemetry-instrumentation-logging==0.59b0 -opentelemetry-instrumentation-httpx==0.59b0 -opentelemetry-instrumentation-aiohttp-client==0.59b0 +opentelemetry-api==1.39.0 +opentelemetry-sdk==1.39.0 +opentelemetry-exporter-otlp==1.39.0 +opentelemetry-instrumentation==0.60b0 +opentelemetry-instrumentation-fastapi==0.60b0 +opentelemetry-instrumentation-sqlalchemy==0.60b0 +opentelemetry-instrumentation-redis==0.60b0 +opentelemetry-instrumentation-requests==0.60b0 +opentelemetry-instrumentation-logging==0.60b0 +opentelemetry-instrumentation-httpx==0.60b0 +opentelemetry-instrumentation-aiohttp-client==0.60b0 diff --git a/docker-compose.playwright.yaml b/docker-compose.playwright.yaml index 4567a0ef62..e00a28df58 100644 --- a/docker-compose.playwright.yaml +++ b/docker-compose.playwright.yaml @@ -1,8 +1,8 @@ services: playwright: - image: mcr.microsoft.com/playwright:v1.56.0-noble # Version must match requirements.txt + image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt container_name: playwright - command: npx -y playwright@1.56.0 run-server --port 3000 --host 0.0.0.0 + command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0 open-webui: environment: diff --git a/pyproject.toml b/pyproject.toml index 3cae025265..b160b67dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.123.0", + "fastapi==0.124.0", "uvicorn[standard]==0.37.0", "pydantic==2.12.5", "python-multipart==0.0.20", @@ -21,7 +21,7 @@ dependencies = [ "authlib==1.6.5", "requests==2.32.5", - "aiohttp==3.12.15", + "aiohttp==3.13.2", "async-timeout", "aiocache", "aiofiles", @@ -29,26 +29,26 @@ dependencies = [ "httpx[socks,http2,zstd,cli,brotli]==0.28.1", "starsessions[redis]==2.2.1", - "sqlalchemy==2.0.38", + "sqlalchemy==2.0.44", "alembic==1.17.2", "peewee==3.18.3", "peewee-migrate==1.14.3", - "pycrdt==0.12.25", + "pycrdt==0.12.44", "redis", - "APScheduler==3.10.4", - "RestrictedPython==8.0", + "APScheduler==3.11.1", + "RestrictedPython==8.1", "loguru==0.7.3", "asgiref==3.11.0", "tiktoken", - "mcp==1.22.0", + "mcp==1.23.3", "openai", "anthropic", - "google-genai==1.52.0", + "google-genai==1.54.0", "google-generativeai==0.8.5", "langchain==0.3.27", @@ -56,62 +56,62 @@ dependencies = [ "fake-useragent==2.2.0", "chromadb==1.3.5", - "opensearch-py==2.8.0", - "PyMySQL==1.1.1", - "boto3==1.41.5", + "opensearch-py==3.1.0", + "PyMySQL==1.1.2", + "boto3==1.42.5", "transformers==4.57.3", "sentence-transformers==5.1.2", "accelerate", - "pyarrow==20.0.0", + "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.1", "ftfy==6.3.1", "chardet==5.2.0", - "pypdf==6.4.0", - "fpdf2==2.8.2", - "pymdown-extensions==10.17.2", - "docx2txt==0.8", + "pypdf==6.4.1", + "fpdf2==2.8.5", + "pymdown-extensions==10.18", + "docx2txt==0.9", "python-pptx==1.0.2", "unstructured==0.18.21", "msoffcrypto-tool==5.4.2", - "nltk==3.9.1", + "nltk==3.9.2", "Markdown==3.10", "pypandoc==1.16.2", - "pandas==2.2.3", + "pandas==2.3.3", "openpyxl==3.1.5", "pyxlsb==1.0.10", - "xlrd==2.0.1", + "xlrd==2.0.2", "validators==0.35.0", "psutil", "sentencepiece", "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==11.3.0", - "opencv-python-headless==4.11.0.86", + "pillow==12.0.0", + "opencv-python-headless==4.12.0.88", "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", - "onnxruntime==1.20.1", - "faster-whisper==1.1.1", + "onnxruntime==1.23.2", + "faster-whisper==1.2.1", - "black==25.11.0", - "youtube-transcript-api==1.2.2", + "black==25.12.0", + "youtube-transcript-api==1.2.3", "pytube==15.0.0", "pydub", - "ddgs==9.9.2", + "ddgs==9.9.3", "google-api-python-client", "google-auth-httplib2", "google-auth-oauthlib", "googleapis-common-protos==1.72.0", - "google-cloud-storage==2.19.0", + "google-cloud-storage==3.7.0", - "azure-identity==1.25.0", - "azure-storage-blob==12.24.1", + "azure-identity==1.25.1", + "azure-storage-blob==12.27.1", "ldap3==2.9.1", ] @@ -130,8 +130,8 @@ classifiers = [ [project.optional-dependencies] postgres = [ - "psycopg2-binary==2.9.10", - "pgvector==0.4.1", + "psycopg2-binary==2.9.11", + "pgvector==0.4.2", ] all = [ @@ -143,17 +143,18 @@ all = [ "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.2.5", - "playwright==1.56.0", - "elasticsearch==9.1.0", + "playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary + "elasticsearch==9.2.0", "qdrant-client==1.16.1", - "weaviate-client==4.17.0", + "pymilvus==2.6.4", + "weaviate-client==4.18.3", "pymilvus==2.6.5", "pinecone==6.0.2", - "oracledb==3.2.0", - "colbert-ai==0.2.21", + "oracledb==3.4.1", + "colbert-ai==0.2.22", - "firecrawl-py==4.10.0", + "firecrawl-py==4.10.4", "azure-search-documents==11.6.0", ] From c24b1207a07fbe56e110dadcd98a8a9ca872b042 Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Wed, 10 Dec 2025 04:29:27 +0800 Subject: [PATCH 08/58] fix: fixed missing text in the explanation feature (#19829) --- backend/open_webui/utils/misc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 5e3f3c4834..21943caff8 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -624,14 +624,17 @@ def stream_chunks_handler(stream: aiohttp.StreamReader): yield line else: yield b"data: {}" + yield b"\n" else: # Normal mode: check if line exceeds limit if len(line) > max_buffer_size: skip_mode = True yield b"data: {}" + yield b"\n" log.info(f"Skip mode triggered, line size: {len(line)}") else: yield line + yield b"\n" # Save the last incomplete fragment buffer = lines[-1] @@ -646,5 +649,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader): # Process remaining buffer data if buffer and not skip_mode: yield buffer + yield b"\n" return yield_safe_stream_chunks() From 1ea555a5acda29eea2193ec0616c7064f0a4e7f2 Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Wed, 10 Dec 2025 04:29:43 +0800 Subject: [PATCH 09/58] i18n: improve Chinese translation (#19830) --- src/lib/i18n/locales/zh-CN/translation.json | 1 + src/lib/i18n/locales/zh-TW/translation.json | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/i18n/locales/zh-CN/translation.json b/src/lib/i18n/locales/zh-CN/translation.json index c8d7b350e8..3b54fa6c69 100644 --- a/src/lib/i18n/locales/zh-CN/translation.json +++ b/src/lib/i18n/locales/zh-CN/translation.json @@ -1624,6 +1624,7 @@ "Tika": "Tika", "Tika Server URL required.": "请输入 Tika 服务器接口地址", "Tiktoken": "Tiktoken", + "Timeout": "超时时间", "Title": "标题", "Title Auto-Generation": "自动生成标题", "Title cannot be an empty string.": "标题不能为空", diff --git a/src/lib/i18n/locales/zh-TW/translation.json b/src/lib/i18n/locales/zh-TW/translation.json index 6e610e3fa1..38cd055dac 100644 --- a/src/lib/i18n/locales/zh-TW/translation.json +++ b/src/lib/i18n/locales/zh-TW/translation.json @@ -1624,6 +1624,7 @@ "Tika": "Tika", "Tika Server URL required.": "需要提供 Tika 伺服器 URL。", "Tiktoken": "Tiktoken", + "Timeout": "逾時時間", "Title": "標題", "Title Auto-Generation": "自動產生標題", "Title cannot be an empty string.": "標題不能是空字串。", From 9b24cddef6c4862bd899eb8d6332cafff54e871d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 16:45:08 -0500 Subject: [PATCH 10/58] enh/refac: notes --- backend/open_webui/models/notes.py | 205 ++++-- backend/open_webui/routers/notes.py | 102 ++- src/lib/apis/notes/index.ts | 56 +- .../components/common/DropdownOptions.svelte | 54 ++ src/lib/components/notes/Notes.svelte | 588 +++++++++++------- 5 files changed, 688 insertions(+), 317 deletions(-) create mode 100644 src/lib/components/common/DropdownOptions.svelte diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index af75fab598..0942ad0140 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -7,12 +7,15 @@ from functools import lru_cache from open_webui.internal.db import Base, get_db from open_webui.models.groups import Groups from open_webui.utils.access_control import has_access -from open_webui.models.users import Users, UserResponse +from open_webui.models.users import User, UserModel, Users, UserResponse from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON -from sqlalchemy import or_, func, select, and_, text +from sqlalchemy.dialects.postgresql import JSONB + + +from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func from sqlalchemy.sql import exists #################### @@ -75,7 +78,63 @@ class NoteUserResponse(NoteModel): user: Optional[UserResponse] = None +class NoteItemResponse(BaseModel): + id: str + title: str + data: Optional[dict] + updated_at: int + created_at: int + user: Optional[UserResponse] = None + + +class NoteListResponse(BaseModel): + items: list[NoteUserResponse] + total: int + + class NoteTable: + def _has_permission(self, db, query, filter: dict, permission: str = "read"): + group_ids = filter.get("group_ids", []) + user_id = filter.get("user_id") + + dialect_name = db.bind.dialect.name + + # Public access + conditions = [] + if group_ids or user_id: + conditions.extend( + [ + Note.access_control.is_(None), + cast(Note.access_control, String) == "null", + ] + ) + + # User-level permission + if user_id: + conditions.append(Note.user_id == user_id) + + # Group-level permission + if group_ids: + group_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_conditions.append( + Note.access_control[permission]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_conditions.append( + cast( + Note.access_control[permission]["group_ids"], + JSONB, + ).contains([gid]) + ) + conditions.append(or_(*group_conditions)) + + if conditions: + query = query.filter(or_(*conditions)) + + return query + def insert_new_note( self, form_data: NoteForm, @@ -110,15 +169,103 @@ class NoteTable: notes = query.all() return [NoteModel.model_validate(note) for note in notes] + def search_notes( + self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 + ) -> NoteListResponse: + with get_db() as db: + query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + Note.title.ilike(f"%{query_key}%"), + Note.data["content"]["md"].ilike(f"%{query_key}%"), + ) + ) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(Note.user_id == user_id) + elif view_option == "shared": + query = query.filter(Note.user_id != user_id) + + # Apply access control filtering + query = self._has_permission( + db, + query, + filter, + permission="write", + ) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(Note.title.asc()) + else: + query = query.order_by(Note.title.desc()) + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(Note.created_at.asc()) + else: + query = query.order_by(Note.created_at.desc()) + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(Note.updated_at.asc()) + else: + query = query.order_by(Note.updated_at.desc()) + else: + query = query.order_by(Note.updated_at.desc()) + + else: + query = query.order_by(Note.updated_at.desc()) + + for key, value in filter.items(): + query = query.filter(getattr(Note, key).ilike(f"%{value}%")) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + notes = [] + for note, user in items: + notes.append( + NoteUserResponse( + **NoteModel.model_validate(note).model_dump(), + user=( + UserResponse(**UserModel.model_validate(user).model_dump()) + if user + else None + ), + ) + ) + + return NoteListResponse(items=notes, total=total) + def get_notes_by_user_id( self, user_id: str, + permission: str = "read", skip: Optional[int] = None, limit: Optional[int] = None, ) -> list[NoteModel]: with get_db() as db: - query = db.query(Note).filter(Note.user_id == user_id) - query = query.order_by(Note.updated_at.desc()) + user_group_ids = [ + group.id for group in Groups.get_groups_by_member_id(user_id) + ] + + query = db.query(Note).order_by(Note.updated_at.desc()) + query = self._has_permission( + db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission + ) if skip is not None: query = query.offset(skip) @@ -128,56 +275,6 @@ class NoteTable: notes = query.all() return [NoteModel.model_validate(note) for note in notes] - def get_notes_by_permission( - self, - user_id: str, - permission: str = "write", - skip: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[NoteModel]: - with get_db() as db: - user_groups = Groups.get_groups_by_member_id(user_id) - user_group_ids = {group.id for group in user_groups} - - # Order newest-first. We stream to keep memory usage low. - query = ( - db.query(Note) - .order_by(Note.updated_at.desc()) - .execution_options(stream_results=True) - .yield_per(256) - ) - - results: list[NoteModel] = [] - n_skipped = 0 - - for note in query: - # Fast-pass #1: owner - if note.user_id == user_id: - permitted = True - # Fast-pass #2: public/open - elif note.access_control is None: - # Technically this should mean public access for both read and write, but we'll only do read for now - # We might want to change this behavior later - permitted = permission == "read" - else: - permitted = has_access( - user_id, permission, note.access_control, user_group_ids - ) - - if not permitted: - continue - - # Apply skip AFTER permission filtering so it counts only accessible notes - if skip and n_skipped < skip: - n_skipped += 1 - continue - - results.append(NoteModel.model_validate(note)) - if limit is not None and len(results) >= limit: - break - - return results - def get_note_by_id(self, id: str) -> Optional[NoteModel]: with get_db() as db: note = db.query(Note).filter(Note.id == id).first() diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 3858c4670f..6cbbc4eaf3 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -8,11 +8,21 @@ from pydantic import BaseModel from open_webui.socket.main import sio - +from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse -from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse +from open_webui.models.notes import ( + NoteListResponse, + Notes, + NoteModel, + NoteForm, + NoteUserResponse, +) -from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT +from open_webui.config import ( + BYPASS_ADMIN_ACCESS_CONTROL, + ENABLE_ADMIN_CHAT_ACCESS, + ENABLE_ADMIN_EXPORT, +) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -30,39 +40,17 @@ router = APIRouter() ############################ -@router.get("/", response_model=list[NoteUserResponse]) -async def get_notes(request: Request, user=Depends(get_verified_user)): - - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS - ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.UNAUTHORIZED, - ) - - notes = [ - NoteUserResponse( - **{ - **note.model_dump(), - "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), - } - ) - for note in Notes.get_notes_by_permission(user.id, "write") - ] - - return notes - - -class NoteTitleIdResponse(BaseModel): +class NoteItemResponse(BaseModel): id: str title: str + data: Optional[dict] updated_at: int created_at: int + user: Optional[UserResponse] = None -@router.get("/list", response_model=list[NoteTitleIdResponse]) -async def get_note_list( +@router.get("/", response_model=list[NoteItemResponse]) +async def get_notes( request: Request, page: Optional[int] = None, user=Depends(get_verified_user) ): if user.role != "admin" and not has_permission( @@ -80,15 +68,61 @@ async def get_note_list( skip = (page - 1) * limit notes = [ - NoteTitleIdResponse(**note.model_dump()) - for note in Notes.get_notes_by_permission( - user.id, "write", skip=skip, limit=limit + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } ) + for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit) ] - return notes +@router.get("/search", response_model=NoteListResponse) +async def search_notes( + request: Request, + query: Optional[str] = None, + view_option: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + return Notes.search_notes(user.id, filter, skip=skip, limit=limit) + + ############################ # CreateNewNote ############################ diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 61794f6766..945b8f2261 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -91,6 +91,60 @@ export const getNotes = async (token: string = '', raw: boolean = false) => { return grouped; }; +export const searchNotes = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + sortKey: string | null = null, + page: number | null = null +) => { + let error = null; + const searchParams = new URLSearchParams(); + + if (query !== null) { + searchParams.append('query', query); + } + + if (viewOption !== null) { + searchParams.append('view_option', viewOption); + } + + if (sortKey !== null) { + searchParams.append('order_by', sortKey); + } + + if (page !== null) { + searchParams.append('page', `${page}`); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${searchParams.toString()}`, { + 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 getNoteList = async (token: string = '', page: number | null = null) => { let error = null; const searchParams = new URLSearchParams(); @@ -99,7 +153,7 @@ export const getNoteList = async (token: string = '', page: number | null = null searchParams.append('page', `${page}`); } - const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte new file mode 100644 index 0000000000..f2640672c4 --- /dev/null +++ b/src/lib/components/common/DropdownOptions.svelte @@ -0,0 +1,54 @@ + + + + +
+ {items.find((item) => item.value === value)?.label ?? placeholder} + +
+
+ + +
+ {#each items as item} + + {/each} +
+
+
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 2b377bda6c..50c3dc1556 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -1,9 +1,7 @@ @@ -236,7 +277,7 @@ -
+
{#if loaded} -
-
+
+
+
+
+ {$i18n.t('Notes')} +
+ +
+ {total} +
+
+ +
+ +
+
+
+ +
+
@@ -277,191 +351,249 @@ {/if}
-
-
- {#if Object.keys(notes).length > 0} -
- {#each Object.keys(notes) as timeRange} -
- {$i18n.t(timeRange)} -
+
+
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ +
+
-
- {#each notes[timeRange] as note, idx (note.id)} - + + {#if (items ?? []).length > 0} + {@const notes = groupNotes(items)} + +
+
+ {#each Object.keys(notes) as timeRange} +
+ {$i18n.t(timeRange)} +
+ + {#if displayOption === null} +
- {/each} -
- {/each} + {:else if displayOption === 'grid'} + + {/if} + {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
{:else}
-
-
+
+
{$i18n.t('No Notes')}
-
+
{$i18n.t('Create your first note by clicking on the plus button below.')}
{/if}
- -
-
- - - - - -
-
- - {:else}
From 307b37d5e2992653d78173d5cc221225ff00179e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 17:19:42 -0500 Subject: [PATCH 11/58] refac --- .../components/common/DropdownOptions.svelte | 3 ++ src/lib/components/notes/Notes.svelte | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte index f2640672c4..91f8607538 100644 --- a/src/lib/components/common/DropdownOptions.svelte +++ b/src/lib/components/common/DropdownOptions.svelte @@ -15,6 +15,8 @@ { value: 'top', label: $i18n.t('Top') } ]; + export let onChange: (value: string) => void = () => {}; + let open = false; @@ -44,6 +46,7 @@ on:click={() => { value = item.value; open = false; + onChange(value); }} > {item.label} diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 50c3dc1556..04f7e4a261 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -161,10 +161,9 @@ const init = async () => { reset(); await getItemsPage(); - loaded = true; }; - $: if (query !== undefined && sortKey !== undefined && viewOption !== undefined) { + $: if (loaded && query !== undefined && sortKey !== undefined && viewOption !== undefined) { init(); } @@ -251,6 +250,11 @@ }; onMount(async () => { + viewOption = localStorage?.noteViewOption ?? null; + displayOption = localStorage?.noteDisplayOption ?? null; + + loaded = true; + const dropzoneElement = document.getElementById('notes-container'); dropzoneElement?.addEventListener('dragover', onDragOver); dropzoneElement?.addEventListener('drop', onDrop); @@ -374,6 +378,13 @@ { value: 'created', label: $i18n.t('Created by you') }, { value: 'shared', label: $i18n.t('Shared with you') } ]} + onChange={(value) => { + if (value) { + localStorage.noteViewOption = value; + } else { + delete localStorage.noteViewOption; + } + }} />
@@ -386,6 +397,13 @@ { value: null, label: $i18n.t('List') }, { value: 'grid', label: $i18n.t('Grid') } ]} + onChange={() => { + if (displayOption) { + localStorage.noteDisplayOption = displayOption; + } else { + delete localStorage.noteDisplayOption; + } + }} />
@@ -394,16 +412,16 @@ {@const notes = groupNotes(items)}
-
+
{#each Object.keys(notes) as timeRange}
{$i18n.t(timeRange)}
{#if displayOption === null} -
+
{#each notes[timeRange] as note, idx (note.id)}
Date: Tue, 9 Dec 2025 17:57:15 -0500 Subject: [PATCH 12/58] enh: read only notes --- backend/open_webui/models/notes.py | 88 ++++++++++- backend/open_webui/routers/notes.py | 15 +- src/lib/apis/notes/index.ts | 5 + src/lib/components/notes/NoteEditor.svelte | 171 +++++++++++---------- src/lib/components/notes/Notes.svelte | 57 +++++-- 5 files changed, 236 insertions(+), 100 deletions(-) diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 0942ad0140..d61094b6ff 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -96,11 +96,86 @@ class NoteTable: def _has_permission(self, db, query, filter: dict, permission: str = "read"): group_ids = filter.get("group_ids", []) user_id = filter.get("user_id") - dialect_name = db.bind.dialect.name - # Public access conditions = [] + + # Handle read_only permission separately + if permission == "read_only": + # For read_only, we want items where: + # 1. User has explicit read permission (via groups or user-level) + # 2. BUT does NOT have write permission + # 3. Public items are NOT considered read_only + + read_conditions = [] + + # Group-level read permission + if group_ids: + group_read_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_read_conditions.append( + Note.access_control["read"]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_read_conditions.append( + cast( + Note.access_control["read"]["group_ids"], + JSONB, + ).contains([gid]) + ) + + if group_read_conditions: + read_conditions.append(or_(*group_read_conditions)) + + # Combine read conditions + if read_conditions: + has_read = or_(*read_conditions) + else: + # If no read conditions, return empty result + return query.filter(False) + + # Now exclude items where user has write permission + write_exclusions = [] + + # Exclude items owned by user (they have implicit write) + if user_id: + write_exclusions.append(Note.user_id != user_id) + + # Exclude items where user has explicit write permission via groups + if group_ids: + group_write_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_write_conditions.append( + Note.access_control["write"]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_write_conditions.append( + cast( + Note.access_control["write"]["group_ids"], + JSONB, + ).contains([gid]) + ) + + if group_write_conditions: + # User should NOT have write permission + write_exclusions.append(~or_(*group_write_conditions)) + + # Exclude public items (items without access_control) + write_exclusions.append(Note.access_control.isnot(None)) + write_exclusions.append(cast(Note.access_control, String) != "null") + + # Combine: has read AND does not have write AND not public + if write_exclusions: + query = query.filter(and_(has_read, *write_exclusions)) + else: + query = query.filter(has_read) + + return query + + # Original logic for other permissions (read, write, etc.) + # Public access conditions if group_ids or user_id: conditions.extend( [ @@ -109,7 +184,7 @@ class NoteTable: ] ) - # User-level permission + # User-level permission (owner has all permissions) if user_id: conditions.append(Note.user_id == user_id) @@ -191,11 +266,16 @@ class NoteTable: query = query.filter(Note.user_id != user_id) # Apply access control filtering + if "permission" in filter: + permission = filter["permission"] + else: + permission = "write" + query = self._has_permission( db, query, filter, - permission="write", + permission=permission, ) order_by = filter.get("order_by") diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 6cbbc4eaf3..cf8eb6112f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -84,6 +84,7 @@ async def search_notes( request: Request, query: Optional[str] = None, view_option: Optional[str] = None, + permission: Optional[str] = None, order_by: Optional[str] = None, direction: Optional[str] = None, page: Optional[int] = 1, @@ -108,6 +109,8 @@ async def search_notes( filter["query"] = query if view_option: filter["view_option"] = view_option + if permission: + filter["permission"] = permission if order_by: filter["order_by"] = order_by if direction: @@ -156,7 +159,11 @@ async def create_new_note( ############################ -@router.get("/{id}", response_model=Optional[NoteModel]) +class NoteResponse(NoteModel): + write_access: bool = False + + +@router.get("/{id}", response_model=Optional[NoteResponse]) async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS @@ -180,7 +187,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - return note + write_access = has_access( + user.id, type="write", access_control=note.access_control, strict=False + ) + + return NoteResponse(**note.model_dump(), write_access=write_access) ############################ diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 945b8f2261..55f9427e0d 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -95,6 +95,7 @@ export const searchNotes = async ( token: string = '', query: string | null = null, viewOption: string | null = null, + permission: string | null = null, sortKey: string | null = null, page: number | null = null ) => { @@ -109,6 +110,10 @@ export const searchNotes = async ( searchParams.append('view_option', viewOption); } + if (permission !== null) { + searchParams.append('permission', permission); + } + if (sortKey !== null) { searchParams.append('order_by', sortKey); } diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index f49d8bb7d0..069d717a87 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -157,6 +157,16 @@ if (res) { note = res; files = res.data.files || []; + + if (note?.write_access) { + $socket?.emit('join-note', { + note_id: id, + auth: { + token: localStorage.token + } + }); + $socket?.on('note-events', noteEventHandler); + } } else { goto('/'); return; @@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, onMount(async () => { await tick(); - $socket?.emit('join-note', { - note_id: id, - auth: { - token: localStorage.token - } - }); - $socket?.on('note-events', noteEventHandler); if ($settings?.models) { selectedModelId = $settings?.models[0]; @@ -956,70 +959,72 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, {/if}
- {#if editor} -
-
- + {#if note?.write_access} + {#if editor} +
+
+ - + +
-
+ {/if} + + + + + + + + {/if} - - - - - - - - { downloadHandler(type); @@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, }} >
- + {#if note?.write_access} + + {:else} +
+ {$i18n.t('Read-Only Access')} +
+ {/if} {#if editor}
@@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, image={true} {files} placeholder={$i18n.t('Write something...')} - editable={versionIdx === null && !editing} + editable={versionIdx === null && !editing && note?.write_access} onSelectionUpdate={({ editor }) => { const { from, to } = editor.state.selection; const selectedText = editor.state.doc.textBetween(from, to, ' '); diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 04f7e4a261..c0d461205f 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -163,17 +163,33 @@ await getItemsPage(); }; - $: if (loaded && query !== undefined && sortKey !== undefined && viewOption !== undefined) { + $: if ( + loaded && + query !== undefined && + sortKey !== undefined && + permission !== undefined && + viewOption !== undefined + ) { init(); } const getItemsPage = async () => { itemsLoading = true; - const res = await searchNotes(localStorage.token, query, viewOption, sortKey, page).catch( - () => { - return []; - } - ); + + if (viewOption === 'created') { + permission = null; + } + + const res = await searchNotes( + localStorage.token, + query, + viewOption, + permission, + sortKey, + page + ).catch(() => { + return []; + }); if (res) { console.log(res); @@ -367,7 +383,7 @@ }} >
+ + {#if [null, 'shared'].includes(viewOption)} + + {/if}
@@ -411,17 +438,21 @@ {#if (items ?? []).length > 0} {@const notes = groupNotes(items)} -
+
- {#each Object.keys(notes) as timeRange} + {#each Object.keys(notes) as timeRange, idx}
{$i18n.t(timeRange)}
{#if displayOption === null} -
+
{#each notes[timeRange] as note, idx (note.id)}
{:else if displayOption === 'grid'}
{#each notes[timeRange] as note, idx (note.id)}
Date: Tue, 9 Dec 2025 18:03:01 -0500 Subject: [PATCH 13/58] refac --- src/routes/(app)/notes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(app)/notes/+page.svelte b/src/routes/(app)/notes/+page.svelte index b453a332d2..24dbc9bd16 100644 --- a/src/routes/(app)/notes/+page.svelte +++ b/src/routes/(app)/notes/+page.svelte @@ -110,7 +110,7 @@
-
+
From a4fe823893ca305bcb86591772acd4c4c0bafed6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 19:45:13 -0500 Subject: [PATCH 14/58] refac: styling --- src/lib/components/notes/Notes.svelte | 364 +++++++++++++------------- 1 file changed, 188 insertions(+), 176 deletions(-) diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index c0d461205f..fa1b6a0253 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -435,36 +435,164 @@
- {#if (items ?? []).length > 0} - {@const notes = groupNotes(items)} + {#if items !== null && total !== null} + {#if (items ?? []).length > 0} + {@const notes = groupNotes(items)} -
-
- {#each Object.keys(notes) as timeRange, idx} -
- {$i18n.t(timeRange)} -
- - {#if displayOption === null} +
+
+ {#each Object.keys(notes) as timeRange, idx}
- {#each notes[timeRange] as note, idx (note.id)} -
- -
-
-
- {note.title} + {$i18n.t(timeRange)} +
+ + {#if displayOption === null} +
+ {:else if displayOption === 'grid'} + - {/each} -
- {:else if displayOption === 'grid'} - - {/if} - {/each} + {/each} +
+ {/if} + {/each} - {#if !allItemsLoaded} - { - if (!itemsLoading) { - loadMoreItems(); - } - }} - > -
{ + if (!itemsLoading) { + loadMoreItems(); + } + }} > - -
{$i18n.t('Loading...')}
-
-
- {/if} -
-
- {:else} -
-
-
- {$i18n.t('No Notes')} +
+ +
{$i18n.t('Loading...')}
+
+ + {/if}
+
+ {:else} +
+
+
+ {$i18n.t('No Notes')} +
-
- {$i18n.t('Create your first note by clicking on the plus button below.')} +
+ {$i18n.t('Create your first note by clicking on the plus button below.')} +
+ {/if} + {:else} +
+
{/if}
{:else}
- +
{/if}
From 65d4b22c7cfcd07ce803ee3e229ff199d3f896d6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 19:47:38 -0500 Subject: [PATCH 15/58] refac --- src/lib/components/notes/Notes.svelte | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index fa1b6a0253..9204435ed3 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -461,14 +461,24 @@
-
- {note.title} -
+ +
+ {note.title} +
+
-
- {dayjs(note.updated_at / 1000000).fromNow()} -
+ +
+ {dayjs(note.updated_at / 1000000).fromNow()} +
+
Date: Tue, 9 Dec 2025 20:49:46 -0500 Subject: [PATCH 16/58] feat/enh: create note from input --- backend/open_webui/routers/notes.py | 9 ++- src/lib/components/chat/MessageInput.svelte | 72 +++++++++++++++++---- src/lib/components/icons/PagePlus.svelte | 24 +++++++ src/lib/components/notes/Notes.svelte | 2 +- src/lib/components/notes/utils.ts | 6 +- 5 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 src/lib/components/icons/PagePlus.svelte diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index cf8eb6112f..74914ae5c6 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -135,7 +135,6 @@ async def search_notes( async def create_new_note( request: Request, form_data: NoteForm, user=Depends(get_verified_user) ): - if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS ): @@ -187,8 +186,12 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - write_access = has_access( - user.id, type="write", access_control=note.access_control, strict=False + write_access = ( + user.role == "admin" + or (user.id == note.user_id) + or has_access( + user.id, type="write", access_control=note.access_control, strict=False + ) ) return NoteResponse(**note.model_dump(), write_access=write_access) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 65dc33c5bf..4b91efa365 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,14 +1,22 @@ + + diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 9204435ed3..dce991f33e 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -337,7 +337,7 @@ > - +
{$i18n.t('New Note')}
diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts index 5d398ebaf2..052c48a441 100644 --- a/src/lib/components/notes/utils.ts +++ b/src/lib/components/notes/utils.ts @@ -107,7 +107,7 @@ export const downloadPdf = async (note) => { pdf.save(`${note.title}.pdf`); }; -export const createNoteHandler = async (title: string, content?: string) => { +export const createNoteHandler = async (title: string, md?: string, html?: string) => { // $i18n.t('New Note'), const res = await createNewNote(localStorage.token, { // YYYY-MM-DD @@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => { data: { content: { json: null, - html: content ?? '', - md: content ?? '' + html: html || md || '', + md: md || '' } }, meta: null, From 02df86784372c2aa8b5155d28794bdbab170902c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 20:52:18 -0500 Subject: [PATCH 17/58] refac --- src/lib/components/chat/MessageInput.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 4b91efa365..4633e97d6d 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -753,6 +753,8 @@ ); if (res) { + // Clear the input content saved in session storage. + sessionStorage.removeItem('chat-input'); goto(`/notes/${res.id}`); } }; From b29e7fd0bed79c06144b7d5a19b0d223ac033f98 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 21:01:39 -0500 Subject: [PATCH 18/58] refac/fix: styling --- src/lib/components/channel/ChannelInfoModal/UserList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte index 7f283991b1..6ba0e4e584 100644 --- a/src/lib/components/channel/ChannelInfoModal/UserList.svelte +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -106,7 +106,7 @@
- {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))} - - - - - {/if} - {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
@@ -1725,113 +1675,166 @@
- {:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))} -
- - + {:else} + {#if prompt !== '' && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} + + + {/if} + + {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))} + + + - -
- {:else} - {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} -
- - - -
- {/if} - -
- - -
+ {/if} + + {#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))} +
+ + + + +
+ {:else} +
+ + + +
+ {/if} {/if}
From 3af96c9d4e880a65d679a7fb98831295d577d5c0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 21:11:49 -0500 Subject: [PATCH 20/58] refac: styling --- src/lib/components/chat/MessageInput.svelte | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 045c886f98..0bb9f9520c 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1676,11 +1676,8 @@
{:else} - {#if prompt !== '' && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} - + {#if prompt !== '' && !history?.currentId && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} + +
+
+ {/if} + {#if suggestions} {#key $settings?.richTextInput ?? true} {#key $settings?.showFormattingToolbar ?? false} { prompt = content.md; inputContent = content; diff --git a/src/lib/components/common/InputModal.svelte b/src/lib/components/common/InputModal.svelte new file mode 100644 index 0000000000..d70163c9c7 --- /dev/null +++ b/src/lib/components/common/InputModal.svelte @@ -0,0 +1,79 @@ + + + +
+
+
+ {$i18n.t('Input')} +
+ +
+ +
+
+ { + value = content.md; + inputContent = content; + + onChange(content); + }} + json={true} + value={inputContent?.json} + html={inputContent?.html} + richText={$settings?.richTextInput ?? true} + messageInput={true} + showFormattingToolbar={$settings?.showFormattingToolbar ?? false} + floatingMenuPlacement={'top-start'} + insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} + {autocomplete} + {generateAutoCompletion} + /> +
+
+
+
diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 9f352c1b02..487d1a9083 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -169,7 +169,7 @@ export let documentId = ''; - export let className = 'input-prose'; + export let className = 'input-prose min-h-fit'; export let placeholder = $i18n.t('Type here...'); let _placeholder = placeholder; @@ -1156,7 +1156,5 @@
diff --git a/src/lib/components/icons/Expand.svelte b/src/lib/components/icons/Expand.svelte new file mode 100644 index 0000000000..e11230aa37 --- /dev/null +++ b/src/lib/components/icons/Expand.svelte @@ -0,0 +1,21 @@ + + + From 6a75620fcb1cd9e2559fc56c0d398dbe0826ad80 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 21:53:34 -0500 Subject: [PATCH 22/58] refac: styling --- src/lib/components/chat/MessageInput.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 1982d70549..27fc68d593 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1237,8 +1237,8 @@ id="chat-input-container" > {#if prompt.split('\n').length > 2} -
-
+
+
-
-
+
+
{#if recording}
{:else} +
+ + {#if editing} + + {:else} + { + enhanceNoteHandler(); + }} + onChat={() => { + showPanel = true; + selectedPanel = 'chat'; + }} + > +
+ +
+
+ {/if} +
+
{ displayMediaRecord = false; @@ -1331,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
- -
- - {#if editing} - - {:else} - { - enhanceNoteHandler(); - }} - onChat={() => { - showPanel = true; - selectedPanel = 'chat'; - }} - > -
- -
-
- {/if} -
-
{/if}
From 49d54c5821169e0973ec2355ec93827aae037e85 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 23:33:48 -0500 Subject: [PATCH 24/58] refac --- src/lib/components/chat/MessageInput.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 27fc68d593..0e0d41e4da 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1228,7 +1228,7 @@
Date: Tue, 9 Dec 2025 23:57:46 -0500 Subject: [PATCH 25/58] refac --- src/lib/components/common/DropdownOptions.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte index 91f8607538..ecc5cc9cf8 100644 --- a/src/lib/components/common/DropdownOptions.svelte +++ b/src/lib/components/common/DropdownOptions.svelte @@ -44,7 +44,12 @@ : ' text-gray-500 dark:text-gray-400'}" type="button" on:click={() => { - value = item.value; + if (value === item.value) { + value = null; + } else { + value = item.value; + } + open = false; onChange(value); }} From 94a8439105f30203ea9d729787c9c5978f5c22a2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 10 Dec 2025 00:53:41 -0500 Subject: [PATCH 26/58] feat/enh: kb file pagination --- backend/open_webui/models/files.py | 3 + backend/open_webui/models/knowledge.py | 101 ++- backend/open_webui/models/notes.py | 3 - backend/open_webui/models/users.py | 2 +- backend/open_webui/routers/knowledge.py | 54 ++ src/lib/apis/knowledge/index.ts | 50 ++ .../workspace/Knowledge/KnowledgeBase.svelte | 585 +++++++++--------- .../KnowledgeBase/AddContentMenu.svelte | 10 +- .../Knowledge/KnowledgeBase/Files.svelte | 113 +++- 9 files changed, 602 insertions(+), 319 deletions(-) diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 1ed743df87..79117b869a 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -238,6 +238,7 @@ class FilesTable: try: file = db.query(File).filter_by(id=id).first() file.hash = hash + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) @@ -249,6 +250,7 @@ class FilesTable: try: file = db.query(File).filter_by(id=id).first() file.data = {**(file.data if file.data else {}), **data} + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) except Exception as e: @@ -260,6 +262,7 @@ class FilesTable: try: file = db.query(File).filter_by(id=id).first() file.meta = {**(file.meta if file.meta else {}), **meta} + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) except Exception: diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 2c72401181..cc3ef7adee 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -7,9 +7,14 @@ import uuid from open_webui.internal.db import Base, get_db from open_webui.env import SRC_LOG_LEVELS -from open_webui.models.files import File, FileModel, FileMetadataResponse +from open_webui.models.files import ( + File, + FileModel, + FileMetadataResponse, + FileModelResponse, +) from open_webui.models.groups import Groups -from open_webui.models.users import Users, UserResponse +from open_webui.models.users import User, UserModel, Users, UserResponse from pydantic import BaseModel, ConfigDict @@ -21,6 +26,7 @@ from sqlalchemy import ( Text, JSON, UniqueConstraint, + or_, ) from open_webui.utils.access_control import has_access @@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel): access_control: Optional[dict] = None +class FileUserResponse(FileModelResponse): + user: Optional[UserResponse] = None + + +class KnowledgeFileListResponse(BaseModel): + items: list[FileUserResponse] + total: int + + class KnowledgeTable: def insert_new_knowledge( self, user_id: str, form_data: KnowledgeForm @@ -232,6 +247,88 @@ class KnowledgeTable: except Exception: return [] + def search_files_by_id( + self, + knowledge_id: str, + user_id: str, + filter: dict, + skip: int = 0, + limit: int = 30, + ) -> KnowledgeFileListResponse: + try: + with get_db() as db: + query = ( + db.query(File, User) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .outerjoin(User, User.id == KnowledgeFile.user_id) + .filter(KnowledgeFile.knowledge_id == knowledge_id) + ) + + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter(or_(File.filename.ilike(f"%{query_key}%"))) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(KnowledgeFile.user_id == user_id) + elif view_option == "shared": + query = query.filter(KnowledgeFile.user_id != user_id) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(File.filename.asc()) + else: + query = query.order_by(File.filename.desc()) + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(File.created_at.asc()) + else: + query = query.order_by(File.created_at.desc()) + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(File.updated_at.asc()) + else: + query = query.order_by(File.updated_at.desc()) + else: + query = query.order_by(File.updated_at.desc()) + + else: + query = query.order_by(File.updated_at.desc()) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + files = [] + for file, user in items: + files.append( + FileUserResponse( + **FileModel.model_validate(file).model_dump(), + user=( + UserResponse( + **UserModel.model_validate(user).model_dump() + ) + if user + else None + ), + ) + ) + + return KnowledgeFileListResponse(items=files, total=total) + except Exception as e: + print(e) + return KnowledgeFileListResponse(items=[], total=0) + def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: try: with get_db() as db: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index d61094b6ff..2ca932ff7f 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -302,9 +302,6 @@ class NoteTable: else: query = query.order_by(Note.updated_at.desc()) - for key, value in filter.items(): - query = query.filter(getattr(Note, key).ilike(f"%{value}%")) - # Count BEFORE pagination total = query.count() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 86f9d011e8..5807603a89 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL + from open_webui.models.chats import Chats from open_webui.models.groups import Groups, GroupMember from open_webui.models.channels import ChannelMember - from open_webui.utils.misc import throttle diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 3bfc961ac3..f67390518b 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool import logging from open_webui.models.knowledge import ( + KnowledgeFileListResponse, Knowledges, KnowledgeForm, KnowledgeResponse, @@ -264,6 +265,59 @@ async def update_knowledge_by_id( ) +############################ +# GetKnowledgeFilesById +############################ + + +@router.get("/{id}/files", response_model=KnowledgeFileListResponse) +async def get_knowledge_files_by_id( + id: str, + query: Optional[str] = None, + view_option: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + + knowledge = Knowledges.get_knowledge_by_id(id=id) + if not knowledge: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not ( + user.role == "admin" + or knowledge.user_id == user.id + or has_access(user.id, "read", knowledge.access_control) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + page = max(page, 1) + + limit = 30 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + return Knowledges.search_files_by_id( + id, user.id, filter=filter, skip=skip, limit=limit + ) + + ############################ # AddFileToKnowledge ############################ diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index c01c986a2a..98b2c1e5ec 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => { return res; }; +export const searchKnowledgeFilesById = async ( + token: string, + id: string, + query?: string | null = null, + viewOption?: string | null = null, + orderBy?: string | null = null, + direction?: string | null = null, + page: number = 1 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (orderBy) searchParams.append('order_by', orderBy); + if (direction) searchParams.append('direction', direction); + searchParams.append('page', page.toString()); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`, + { + 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; +}; + type KnowledgeUpdateForm = { name?: string; description?: string; diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index d09e608d51..1dff4bd8f8 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -31,7 +31,8 @@ removeFileFromKnowledgeById, resetKnowledgeById, updateFileFromKnowledgeById, - updateKnowledgeById + updateKnowledgeById, + searchKnowledgeFilesById } from '$lib/apis/knowledge'; import { blobToFile } from '$lib/utils'; @@ -43,22 +44,25 @@ import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte'; import SyncConfirmDialog from '../../common/ConfirmDialog.svelte'; - import RichTextInput from '$lib/components/common/RichTextInput.svelte'; - import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte'; import Drawer from '$lib/components/common/Drawer.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte'; import Search from '$lib/components/icons/Search.svelte'; - import Textarea from '$lib/components/common/Textarea.svelte'; import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte'; + import DropdownOptions from '$lib/components/common/DropdownOptions.svelte'; + import Pagination from '$lib/components/common/Pagination.svelte'; let largeScreen = true; let pane; let showSidepanel = true; - let minSize = 0; + let showAddTextContentModal = false; + let showSyncConfirmModal = false; + let showAccessControlModal = false; + + let minSize = 0; type Knowledge = { id: string; name: string; @@ -71,52 +75,88 @@ let id = null; let knowledge: Knowledge | null = null; - let query = ''; - let showAddTextContentModal = false; - let showSyncConfirmModal = false; - let showAccessControlModal = false; + let selectedFileId = null; + let selectedFile = null; + let selectedFileContent = ''; let inputFiles = null; - let filteredItems = []; - $: if (knowledge && knowledge.files) { - fuse = new Fuse(knowledge.files, { - keys: ['meta.name', 'meta.description'] - }); + let query = ''; + let viewOption = null; + let sortKey = null; + let direction = null; + + let currentPage = 1; + let fileItems = null; + let fileItemsTotal = null; + + const reset = () => { + currentPage = 1; + }; + + const init = async () => { + reset(); + await getItemsPage(); + }; + + $: if ( + knowledge !== null && + query !== undefined && + viewOption !== undefined && + sortKey !== undefined && + direction !== undefined && + currentPage !== undefined + ) { + getItemsPage(); } - $: if (fuse) { - filteredItems = query - ? fuse.search(query).map((e) => { - return e.item; - }) - : (knowledge?.files ?? []); + $: if ( + query !== undefined && + viewOption !== undefined && + sortKey !== undefined && + direction !== undefined + ) { + reset(); } - let selectedFile = null; - let selectedFileId = null; - let selectedFileContent = ''; + const getItemsPage = async () => { + if (knowledge === null) return; - // Add cache object - let fileContentCache = new Map(); + fileItems = null; + fileItemsTotal = null; - $: if (selectedFileId) { - const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId); - if (file) { - fileSelectHandler(file); - } else { - selectedFile = null; + if (sortKey === null) { + direction = null; } - } else { - selectedFile = null; - } - let fuse = null; - let debounceTimeout = null; - let mediaQuery; - let dragged = false; - let isSaving = false; + const res = await searchKnowledgeFilesById( + localStorage.token, + knowledge.id, + query, + viewOption, + sortKey, + direction, + currentPage + ).catch(() => { + return null; + }); + + if (res) { + fileItems = res.items; + fileItemsTotal = res.total; + } + return res; + }; + + const fileSelectHandler = async (file) => { + try { + selectedFile = file; + selectedFileContent = selectedFile?.data?.content || ''; + } catch (e) { + toast.error($i18n.t('Failed to load file content.')); + } + }; const createFileFromText = (name, content) => { const blob = new Blob([content], { type: 'text/plain' }); @@ -163,8 +203,7 @@ return; } - knowledge.files = [...(knowledge.files ?? []), fileItem]; - + fileItems = [...(fileItems ?? []), fileItem]; try { // If the file is an audio file, provide the language for STT. let metadata = null; @@ -184,7 +223,7 @@ if (uploadedFile) { console.log(uploadedFile); - knowledge.files = knowledge.files.map((item) => { + fileItems = fileItems.map((item) => { if (item.itemId === tempItemId) { item.id = uploadedFile.id; } @@ -197,7 +236,7 @@ if (uploadedFile.error) { console.warn('File upload warning:', uploadedFile.error); toast.warning(uploadedFile.error); - knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id); + fileItems = fileItems.filter((file) => file.id !== uploadedFile.id); } else { await addFileHandler(uploadedFile.id); } @@ -413,7 +452,7 @@ toast.success($i18n.t('File added successfully.')); } else { toast.error($i18n.t('Failed to add file.')); - knowledge.files = knowledge.files.filter((file) => file.id !== fileId); + fileItems = fileItems.filter((file) => file.id !== fileId); } }; @@ -436,32 +475,38 @@ } }; + let debounceTimeout = null; + let mediaQuery; + + let dragged = false; + let isSaving = false; + const updateFileContentHandler = async () => { if (isSaving) { console.log('Save operation already in progress, skipping...'); return; } + isSaving = true; + try { - const fileId = selectedFile.id; - const content = selectedFileContent; - // Clear the cache for this file since we're updating it - fileContentCache.delete(fileId); - const res = await updateFileDataContentById(localStorage.token, fileId, content).catch( - (e) => { - toast.error(`${e}`); - } - ); - const updatedKnowledge = await updateFileFromKnowledgeById( + const res = await updateFileDataContentById( localStorage.token, - id, - fileId + selectedFile.id, + selectedFileContent ).catch((e) => { toast.error(`${e}`); + return null; }); - if (res && updatedKnowledge) { - knowledge = updatedKnowledge; + + if (res) { toast.success($i18n.t('File content updated successfully.')); + + selectedFileId = null; + selectedFile = null; + selectedFileContent = ''; + + await init(); } } finally { isSaving = false; @@ -504,29 +549,6 @@ } }; - const fileSelectHandler = async (file) => { - try { - selectedFile = file; - - // Check cache first - if (fileContentCache.has(file.id)) { - selectedFileContent = fileContentCache.get(file.id); - return; - } - - const response = await getFileById(localStorage.token, file.id); - if (response) { - selectedFileContent = response.data.content; - // Cache the content - fileContentCache.set(file.id, response.data.content); - } else { - toast.error($i18n.t('No content found in file.')); - } - } catch (e) { - toast.error($i18n.t('Failed to load file content.')); - } - }; - const onDragOver = (e) => { e.preventDefault(); @@ -705,32 +727,42 @@ }} /> -
+
{#if id && knowledge} { changeDebounceHandler(); }} accessRoles={['read', 'write']} /> -
+
-
-
+
+
{ changeDebounceHandler(); }} /> + +
+ {#if (knowledge?.files ?? []).length} +
+ {$i18n.t('{{count}} files', { + count: (knowledge?.files ?? []).length + })} +
+ {/if} +
@@ -750,7 +782,7 @@
-
+
-
- {#if largeScreen} -
- {#if selectedFile} -
-
- {#if !showSidepanel} -
- -
- {/if} +
+
+
+
+ +
+ { + selectedFileId = null; + }} + /> - - -
- -
-
- -
- {#key selectedFile.id} -