From 69722ba973768a5f689f2e2351bf583a8db9bba8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 06:32:27 -0500 Subject: [PATCH 01/43] fix/refac: workspace shared model list --- backend/open_webui/models/models.py | 33 +++++++++++++++++++++++++--- backend/open_webui/routers/models.py | 5 +++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index e902a978d1..329b87a91f 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -220,6 +220,34 @@ class ModelsTable: or has_access(user_id, permission, model.access_control, user_group_ids) ] + def _has_write_permission(self, query, filter: dict): + if filter.get("group_ids") or filter.get("user_id"): + conditions = [] + + # --- ANY group_ids match ("write".group_ids) --- + if filter.get("group_ids"): + group_ids = filter["group_ids"] + like_clauses = [] + + for gid in group_ids: + like_clauses.append( + cast(Model.access_control, String).like( + f'%"write"%"group_ids"%"{gid}"%' + ) + ) + + # ANY → OR + conditions.append(or_(*like_clauses)) + + # --- user_id match (owner) --- + if filter.get("user_id"): + conditions.append(Model.user_id == filter["user_id"]) + + # Apply OR across the two groups of conditions + query = query.filter(or_(*conditions)) + + return query + def search_models( self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 ) -> ModelListResponse: @@ -238,11 +266,10 @@ class ModelsTable: ) ) - if filter.get("user_id"): - query = query.filter(Model.user_id == filter.get("user_id")) + # Apply access control filtering + query = self._has_write_permission(query, filter) view_option = filter.get("view_option") - if view_option == "created": query = query.filter(Model.user_id == user_id) elif view_option == "shared": diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 93d8cb8bf7..df5a7377dc 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -5,6 +5,7 @@ import json import asyncio import logging +from open_webui.models.groups import Groups from open_webui.models.models import ( ModelForm, ModelModel, @@ -78,6 +79,10 @@ async def get_models( 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 Models.search_models(user.id, filter=filter, skip=skip, limit=limit) From 1bfe2c92ba27c67b17f1141aeded15e6ec99d150 Mon Sep 17 00:00:00 2001 From: Aleix Dorca Date: Tue, 25 Nov 2025 13:03:49 +0100 Subject: [PATCH 02/43] Merge pull request #19464 from aleixdorca/dev i18n: Update Catalan translation.json --- src/lib/i18n/locales/ca-ES/translation.json | 44 ++++++++++----------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/lib/i18n/locales/ca-ES/translation.json b/src/lib/i18n/locales/ca-ES/translation.json index 5309cb3734..f037683a51 100644 --- a/src/lib/i18n/locales/ca-ES/translation.json +++ b/src/lib/i18n/locales/ca-ES/translation.json @@ -94,7 +94,7 @@ "Allow Continue Response": "Permetre continuar la resposta", "Allow Delete Messages": "Permetre eliminar missatges", "Allow File Upload": "Permetre la pujada d'arxius", - "Allow Group Sharing": "", + "Allow Group Sharing": "Permetre compartir en grup", "Allow Multiple Models in Chat": "Permetre múltiple models al xat", "Allow non-local voices": "Permetre veus no locals", "Allow Rate Response": "Permetre valorar les respostes", @@ -142,7 +142,7 @@ "Archived Chats": "Xats arxivats", "archived-chat-export": "archived-chat-export", "Are you sure you want to clear all memories? This action cannot be undone.": "Estàs segur que vols netejar totes les memòries? Aquesta acció no es pot desfer.", - "Are you sure you want to delete \"{{NAME}}\"?": "", + "Are you sure you want to delete \"{{NAME}}\"?": "Estàs segur que vols eliminar \"{{NAME}}\"?", "Are you sure you want to delete this channel?": "Estàs segur que vols eliminar aquest canal?", "Are you sure you want to delete this message?": "Estàs segur que vols eliminar aquest missatge?", "Are you sure you want to unarchive all archived chats?": "Estàs segur que vols desarxivar tots els xats arxivats?", @@ -152,7 +152,7 @@ "Ask": "Preguntar", "Ask a question": "Fer una pregunta", "Assistant": "Assistent", - "Async Embedding Processing": "", + "Async Embedding Processing": "Procés d'incrustat asíncron", "Attach File From Knowledge": "Adjuntar arxiu del coneixement", "Attach Knowledge": "Adjuntar coneixement", "Attach Notes": "Adjuntar notes", @@ -387,14 +387,14 @@ "Default description enabled": "Descripcions per defecte habilitades", "Default Features": "Característiques per defecte", "Default Filters": "Filres per defecte", - "Default Group": "", + "Default Group": "Grup per defecte", "Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "El mode predeterminat funciona amb una gamma més àmplia de models cridant a les eines una vegada abans de l'execució. El mode natiu aprofita les capacitats de crida d'eines integrades del model, però requereix que el model admeti aquesta funció de manera inherent.", "Default Model": "Model per defecte", "Default model updated": "Model per defecte actualitzat", "Default Models": "Models per defecte", "Default permissions": "Permisos per defecte", "Default permissions updated successfully": "Permisos per defecte actualitzats correctament", - "Default Pinned Models": "", + "Default Pinned Models": "Model marcat per defecte", "Default Prompt Suggestions": "Suggeriments d'indicació per defecte", "Default to 389 or 636 if TLS is enabled": "Per defecte 389 o 636 si TLS està habilitat", "Default to ALL": "Per defecte TOTS", @@ -403,7 +403,7 @@ "Delete": "Eliminar", "Delete a model": "Eliminar un model", "Delete All Chats": "Eliminar tots els xats", - "Delete all contents inside this folder": "", + "Delete all contents inside this folder": "Eliminar tot el contingut d'aquesta carpeta", "Delete All Models": "Eliminar tots els models", "Delete Chat": "Eliminar xat", "Delete chat?": "Eliminar el xat?", @@ -558,7 +558,7 @@ "Enter Datalab Marker API Base URL": "Introdueix la URL de base de l'API Datalab Marker", "Enter Datalab Marker API Key": "Introdueix la clau API de Datalab Marker", "Enter description": "Introdueix la descripció", - "Enter Docling API Key": "", + "Enter Docling API Key": "Introdueix la clau API de Docling", "Enter Docling Server URL": "Introdueix la URL del servidor Docling", "Enter Document Intelligence Endpoint": "Introdueix el punt de connexió de Document Intelligence", "Enter Document Intelligence Key": "Introdueix la clau de Document Intelligence", @@ -573,7 +573,7 @@ "Enter Firecrawl API Base URL": "Introdueix la URL base de Firecrawl API", "Enter Firecrawl API Key": "Introdueix la clau API de Firecrawl", "Enter folder name": "Introdueix el nom de la carpeta", - "Enter function name filter list (e.g. func1, !func2)": "", + "Enter function name filter list (e.g. func1, !func2)": "Introdueix la llista de filtres de noms de funció (per exemple, func1, !func2)", "Enter Github Raw URL": "Introdueix la URL en brut de Github", "Enter Google PSE API Key": "Introdueix la clau API de Google PSE", "Enter Google PSE Engine Id": "Introdueix l'identificador del motor PSE de Google", @@ -725,7 +725,7 @@ "Features": "Característiques", "Features Permissions": "Permisos de les característiques", "February": "Febrer", - "Feedback deleted successfully": "", + "Feedback deleted successfully": "Retorn eliminat correctament", "Feedback Details": "Detalls del retorn", "Feedback History": "Històric de comentaris", "Feedbacks": "Comentaris", @@ -739,8 +739,8 @@ "File removed successfully.": "Arxiu eliminat correctament.", "File size should not exceed {{maxSize}} MB.": "La mida del fitxer no ha de superar els {{maxSize}} MB.", "File Upload": "Pujar arxiu", - "File uploaded successfully": "arxiu pujat satisfactòriament", - "File uploaded!": "", + "File uploaded successfully": "Arxiu pujat satisfactòriament", + "File uploaded!": "Arxiu pujat!", "Files": "Arxius", "Filter": "Filtre", "Filter is now globally disabled": "El filtre ha estat desactivat globalment", @@ -785,7 +785,7 @@ "Function is now globally disabled": "La funció ha estat desactivada globalment", "Function is now globally enabled": "La funció ha estat activada globalment", "Function Name": "Nom de la funció", - "Function Name Filter List": "", + "Function Name Filter List": "Llista de filtres de noms de funció", "Function updated successfully": "La funció s'ha actualitzat correctament", "Functions": "Funcions", "Functions allow arbitrary code execution.": "Les funcions permeten l'execució de codi arbitrari.", @@ -855,7 +855,7 @@ "Image Compression": "Compressió d'imatges", "Image Compression Height": "Alçada de la compressió d'imatges", "Image Compression Width": "Amplada de la compressió d'imatges", - "Image Edit": "", + "Image Edit": "Editar imatge", "Image Edit Engine": "Motor d'edició d'imatges", "Image Generation": "Generació d'imatges", "Image Generation Engine": "Motor de generació d'imatges", @@ -939,7 +939,7 @@ "Knowledge Name": "Nom del coneixement", "Knowledge Public Sharing": "Compartir públicament el Coneixement", "Knowledge reset successfully.": "Coneixement restablert correctament.", - "Knowledge Sharing": "", + "Knowledge Sharing": "Compartir el coneixement", "Knowledge updated successfully": "Coneixement actualitzat correctament.", "Kokoro.js (Browser)": "Kokoro.js (Navegador)", "Kokoro.js Dtype": "Kokoro.js Dtype", @@ -1005,7 +1005,7 @@ "Max Upload Size": "Mida màxima de càrrega", "Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Es poden descarregar un màxim de 3 models simultàniament. Si us plau, prova-ho més tard.", "May": "Maig", - "MBR": "", + "MBR": "MBR", "MCP": "MCP", "MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.": "El suport per a MCP és experimental i la seva especificació canvia sovint, cosa que pot provocar incompatibilitats. El suport per a l'especificació d'OpenAPI el manté directament l'equip d'Open WebUI, cosa que el converteix en l'opció més fiable per a la compatibilitat.", "Medium": "Mig", @@ -1062,7 +1062,7 @@ "Models configuration saved successfully": "La configuració dels models s'ha desat correctament", "Models imported successfully": "Els models s'han importat correctament", "Models Public Sharing": "Compartició pública de models", - "Models Sharing": "", + "Models Sharing": "Compartir els models", "Mojeek Search API Key": "Clau API de Mojeek Search", "More": "Més", "More Concise": "Més precís", @@ -1129,7 +1129,7 @@ "Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Nota: Si s'estableix una puntuació mínima, la cerca només retornarà documents amb una puntuació major o igual a la puntuació mínima.", "Notes": "Notes", "Notes Public Sharing": "Compartició pública de les notes", - "Notes Sharing": "", + "Notes Sharing": "Compartir les notes", "Notification Sound": "So de la notificació", "Notification Webhook": "Webhook de la notificació", "Notifications": "Notificacions", @@ -1268,7 +1268,7 @@ "Prompts": "Indicacions", "Prompts Access": "Accés a les indicacions", "Prompts Public Sharing": "Compartició pública de indicacions", - "Prompts Sharing": "", + "Prompts Sharing": "Compartir les indicacions", "Provider Type": "Tipus de proveïdor", "Public": "Públic", "Pull \"{{searchValue}}\" from Ollama.com": "Obtenir \"{{searchValue}}\" de Ollama.com", @@ -1352,7 +1352,7 @@ "Run": "Executar", "Running": "S'està executant", "Running...": "S'està executant...", - "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "", + "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Executa tasques d'incrustació simultàniament per accelerar el processament. Desactiva-ho si els límits de velocitat es converteixen en un problema.", "Save": "Desar", "Save & Create": "Desar i crear", "Save & Update": "Desar i actualitzar", @@ -1453,7 +1453,7 @@ "Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "Estableix la llavor del nombre aleatori que s'utilitzarà per a la generació. Establir-ho a un número específic farà que el model generi el mateix text per a la mateixa sol·licitud.", "Sets the size of the context window used to generate the next token.": "Estableix la mida de la finestra de context utilitzada per generar el següent token.", "Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Establir les seqüències d'aturada a utilitzar. Quan es trobi aquest patró, el LLM deixarà de generar text. Es poden establir diversos patrons de parada especificant diversos paràmetres de parada separats en un fitxer model.", - "Setting": "", + "Setting": "Preferència", "Settings": "Preferències", "Settings saved successfully!": "Les preferències s'han desat correctament", "Share": "Compartir", @@ -1632,7 +1632,7 @@ "Tools Function Calling Prompt": "Indicació per a la crida de funcions", "Tools have a function calling system that allows arbitrary code execution.": "Les eines disposen d'un sistema de crida a funcions que permet execució de codi arbitrari.", "Tools Public Sharing": "Compartició pública d'eines", - "Tools Sharing": "", + "Tools Sharing": "Compartir les eines", "Top K": "Top K", "Top K Reranker": "Top K Reranker", "Transformers": "Transformadors", @@ -1680,7 +1680,7 @@ "Upload Pipeline": "Pujar una Pipeline", "Upload Progress": "Progrés de càrrega", "Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Progrés de la pujada: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)", - "Uploading file...": "", + "Uploading file...": "Pujant l'arxiu...", "URL": "URL", "URL is required": "La URL és necessaria", "URL Mode": "Mode URL", From c7eb7136893b0ddfdc5d55ffc7a05bd84a00f5d6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 08:00:30 -0500 Subject: [PATCH 03/43] fix: user preview profile image --- backend/open_webui/routers/users.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 0b44e4319a..f9e1c220a9 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -6,7 +6,7 @@ import io from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import Response, StreamingResponse, FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from open_webui.models.auths import Auths @@ -363,6 +363,7 @@ class UserResponse(BaseModel): name: str profile_image_url: str active: Optional[bool] = None + model_config = ConfigDict(extra="allow") @router.get("/{user_id}", response_model=UserResponse) @@ -385,6 +386,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): if user: return UserResponse( **{ + "id": user.id, "name": user.name, "profile_image_url": user.profile_image_url, "active": get_active_status_by_user_id(user_id), From c5b73d71843edc024325d4a6e625ec939a747279 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 16:25:40 -0500 Subject: [PATCH 04/43] refac/fix: function name filter type --- src/lib/components/AddToolServerModal.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 2b639b3e64..79fe4c97fc 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -47,7 +47,7 @@ let key = ''; let headers = ''; - let functionNameFilterList = []; + let functionNameFilterList = ''; let accessControl = {}; let id = ''; @@ -338,7 +338,7 @@ oauthClientInfo = null; enable = true; - functionNameFilterList = []; + functionNameFilterList = ''; accessControl = null; }; @@ -362,7 +362,7 @@ oauthClientInfo = connection.info?.oauth_client_info ?? null; enable = connection.config?.enable ?? true; - functionNameFilterList = connection.config?.function_name_filter_list ?? []; + functionNameFilterList = connection.config?.function_name_filter_list ?? ''; accessControl = connection.config?.access_control ?? null; } }; From 477097c2e42985c14892301d0127314629d07df1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 16:27:27 -0500 Subject: [PATCH 05/43] refac --- backend/open_webui/utils/middleware.py | 11 ++++++----- backend/open_webui/utils/tools.py | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index efa187a382..cc2de8e1c7 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1409,11 +1409,12 @@ async def process_chat_payload(request, form_data, user, metadata, model): headers=headers if headers else None, ) - function_name_filter_list = ( - mcp_server_connection.get("config", {}) - .get("function_name_filter_list", "") - .split(",") - ) + function_name_filter_list = mcp_server_connection.get( + "config", {} + ).get("function_name_filter_list", "") + + if isinstance(function_name_filter_list, str): + function_name_filter_list = function_name_filter_list.split(",") tool_specs = await mcp_clients[server_id].list_tool_specs() for tool_spec in tool_specs: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 268624135d..2baff503ee 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -150,11 +150,12 @@ async def get_tools( ) specs = tool_server_data.get("specs", []) - function_name_filter_list = ( - tool_server_connection.get("config", {}) - .get("function_name_filter_list", "") - .split(",") - ) + function_name_filter_list = tool_server_connection.get( + "config", {} + ).get("function_name_filter_list", "") + + if isinstance(function_name_filter_list, str): + function_name_filter_list = function_name_filter_list.split(",") for spec in specs: function_name = spec["name"] From 8b2015a97b88b05bde0f4631ba67b1108de25bcb Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 16:28:06 -0500 Subject: [PATCH 06/43] refac --- backend/open_webui/routers/retrieval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 6080337250..0b65eedf96 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1249,7 +1249,7 @@ def save_docs_to_vector_db( return ", ".join(docs_info) - log.info( + log.debug( f"save_docs_to_vector_db: document {_get_docs_info(docs)} {collection_name}" ) From 4df5b7eb2e4b39eb01d488de4c95444ce6dea88c Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:28:58 +0100 Subject: [PATCH 07/43] fix: update dependency to prevent rediss:// failure (#19488) * Update pyproject.toml * Update requirements.txt * Update requirements-min.txt --- backend/requirements-min.txt | 2 +- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index c09f1af820..8d63bd4b82 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -7,7 +7,7 @@ pydantic==2.11.9 python-multipart==0.0.20 itsdangerous==2.2.0 -python-socketio==5.14.0 +python-socketio==5.15.0 python-jose==3.5.0 cryptography bcrypt==5.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 658e249090..b1609e5270 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,7 +4,7 @@ pydantic==2.11.9 python-multipart==0.0.20 itsdangerous==2.2.0 -python-socketio==5.14.0 +python-socketio==5.15.0 python-jose==3.5.0 cryptography bcrypt==5.0.0 diff --git a/pyproject.toml b/pyproject.toml index f0568a4237..b71610e955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "python-multipart==0.0.20", "itsdangerous==2.2.0", - "python-socketio==5.14.0", + "python-socketio==5.15.0", "python-jose==3.5.0", "cryptography", "bcrypt==5.0.0", From c6316593270cc5770bf4a2da463f6715e39042bc Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:31:38 +0100 Subject: [PATCH 08/43] i18n: de-de (#19471) --- src/lib/i18n/locales/de-DE/translation.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/i18n/locales/de-DE/translation.json b/src/lib/i18n/locales/de-DE/translation.json index 7bc111b0b7..cb27c85299 100644 --- a/src/lib/i18n/locales/de-DE/translation.json +++ b/src/lib/i18n/locales/de-DE/translation.json @@ -152,7 +152,7 @@ "Ask": "Fragen", "Ask a question": "Stellen Sie eine Frage", "Assistant": "Assistent", - "Async Embedding Processing": "", + "Async Embedding Processing": "Paralleles Embedding Processing", "Attach File From Knowledge": "Datei von Wissensspeicher hinzufügen", "Attach Knowledge": "Wissensspeicher anhängen", "Attach Notes": "Notizen anhängen", @@ -558,7 +558,7 @@ "Enter Datalab Marker API Base URL": "Geben Sie die Basis-URL für die Datalab Marker API ein", "Enter Datalab Marker API Key": "Geben Sie den Datalab Marker API-Schlüssel ein", "Enter description": "Geben Sie eine Beschreibung ein", - "Enter Docling API Key": "", + "Enter Docling API Key": "Docling API Key eingeben", "Enter Docling Server URL": "Docling Server-URL eingeben", "Enter Document Intelligence Endpoint": "Endpunkt für Document Intelligence eingeben", "Enter Document Intelligence Key": "Schlüssel für Document Intelligence eingeben", @@ -573,7 +573,7 @@ "Enter Firecrawl API Base URL": "Geben Sie die Firecrawl Basis-URL ein", "Enter Firecrawl API Key": "Geben Sie den Firecrawl API-Schlüssel ein", "Enter folder name": "Ordnernamen eingeben", - "Enter function name filter list (e.g. func1, !func2)": "", + "Enter function name filter list (e.g. func1, !func2)": "Funktionsnamen Filter-Liste eingeben (z.B. func1, !func2)", "Enter Github Raw URL": "Geben Sie die Github Raw-URL ein", "Enter Google PSE API Key": "Geben Sie den Google PSE-API-Schlüssel ein", "Enter Google PSE Engine Id": "Geben Sie die Google PSE-Engine-ID ein", @@ -785,7 +785,7 @@ "Function is now globally disabled": "Die Funktion ist jetzt global deaktiviert", "Function is now globally enabled": "Die Funktion ist jetzt global aktiviert", "Function Name": "Funktionsname", - "Function Name Filter List": "", + "Function Name Filter List": "Funktionsnamen Filter-Liste", "Function updated successfully": "Funktion erfolgreich aktualisiert", "Functions": "Funktionen", "Functions allow arbitrary code execution.": "Funktionen ermöglichen die Ausführung beliebigen Codes.", @@ -1351,7 +1351,7 @@ "Run": "Ausführen", "Running": "Läuft", "Running...": "Läuft...", - "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "", + "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Lässt embeddings parallel laufen für schnelleres verarbeiten der Dokumente. Schalte es aus, falls Rate Limits oder Rechenressourcen knapp sind.", "Save": "Speichern", "Save & Create": "Erstellen", "Save & Update": "Aktualisieren", From 4370dee79e19d77062c03fba81780cb3b779fca3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 17:19:33 -0500 Subject: [PATCH 09/43] fix: async save docs to vector db --- backend/open_webui/routers/retrieval.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 0b65eedf96..72090e3ba0 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1689,7 +1689,7 @@ async def process_text( log.debug(f"text_content: {text_content}") result = await run_in_threadpool( - save_docs_to_vector_db, request, docs, collection_name, user + save_docs_to_vector_db, request, docs, collection_name, user=user ) if result: return { @@ -1721,7 +1721,12 @@ async def process_web( if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: await run_in_threadpool( - save_docs_to_vector_db, request, docs, collection_name, True, user + save_docs_to_vector_db, + request, + docs, + collection_name, + overwrite=True, + user=user, ) else: collection_name = None @@ -2464,7 +2469,12 @@ async def process_files_batch( if all_docs: try: await run_in_threadpool( - save_docs_to_vector_db, request, all_docs, collection_name, True, user + save_docs_to_vector_db, + request, + all_docs, + collection_name, + add=True, + user=user, ) # Update all files with collection name From 9fca4969dbc23102280e7e56dcf24bd52e71ca87 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:41:12 +0100 Subject: [PATCH 10/43] chore: dep bump pypdf to ver 6.4.0 (#19508) * Update pyproject.toml * Update requirements.txt --- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index b1609e5270..9cc4938c0a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -60,7 +60,7 @@ einops==0.8.1 ftfy==6.2.3 chardet==5.2.0 -pypdf==6.0.0 +pypdf==6.4.0 fpdf2==2.8.2 pymdown-extensions==10.14.2 docx2txt==0.8 diff --git a/pyproject.toml b/pyproject.toml index b71610e955..243ac462cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "ftfy==6.2.3", "chardet==5.2.0", - "pypdf==6.0.0", + "pypdf==6.4.0", "fpdf2==2.8.2", "pymdown-extensions==10.14.2", "docx2txt==0.8", From 4b21704498d0f0e1f48f23d07935a61b35be3b58 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:41:20 +0100 Subject: [PATCH 11/43] chore: Update pymilvus dep (#19507) * Update requirements.txt * Update pyproject.toml --- backend/requirements.txt | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9cc4938c0a..44086e63db 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -115,7 +115,7 @@ pgvector==0.4.1 PyMySQL==1.1.1 boto3==1.40.5 -pymilvus==2.6.2 +pymilvus==2.6.4 qdrant-client==1.14.3 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml elasticsearch==9.1.0 diff --git a/pyproject.toml b/pyproject.toml index 243ac462cf..94f5abc3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,7 +148,7 @@ all = [ "qdrant-client==1.14.3", "weaviate-client==4.17.0", - "pymilvus==2.6.2", + "pymilvus==2.6.4", "pinecone==6.0.2", "oracledb==3.2.0", "colbert-ai==0.2.21", From d071cdf7d49c01b37c7dd1d4364353969ec049a8 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:41:56 +0100 Subject: [PATCH 12/43] chore: update transformers dependency to fix issue #19512 (#19513) * Update pyproject.toml * Update requirements.txt * Update requirements.txt * Update pyproject.toml --- backend/requirements.txt | 4 ++-- pyproject.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 44086e63db..ba8cbbfc07 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -52,8 +52,8 @@ chromadb==1.1.0 weaviate-client==4.17.0 opensearch-py==2.8.0 -transformers -sentence-transformers==5.1.1 +transformers==4.57.3 +sentence-transformers==5.1.2 accelerate pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.1 diff --git a/pyproject.toml b/pyproject.toml index 94f5abc3c9..709f4ec672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,8 @@ dependencies = [ "PyMySQL==1.1.1", "boto3==1.40.5", - "transformers", - "sentence-transformers==5.1.1", + "transformers==4.57.3", + "sentence-transformers==5.1.2", "accelerate", "pyarrow==20.0.0", "einops==0.8.1", From f2d6a425de47127f2bd0487a9c982783c3b4c9f2 Mon Sep 17 00:00:00 2001 From: gerhardj-b <110168424+gerhardj-b@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:38:26 +0100 Subject: [PATCH 13/43] feat: also consider OAUTH_ROLES_SEPARATOR for string claims themselves (#19514) --- backend/open_webui/config.py | 10 +++++++--- backend/open_webui/utils/oauth.py | 9 ++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 5a9844c067..ec62c8ba01 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -583,14 +583,16 @@ OAUTH_ROLES_CLAIM = PersistentConfig( os.environ.get("OAUTH_ROLES_CLAIM", "roles"), ) -SEP = os.environ.get("OAUTH_ROLES_SEPARATOR", ",") +OAUTH_ROLES_SEPARATOR = os.environ.get("OAUTH_ROLES_SEPARATOR", ",") OAUTH_ALLOWED_ROLES = PersistentConfig( "OAUTH_ALLOWED_ROLES", "oauth.allowed_roles", [ role.strip() - for role in os.environ.get("OAUTH_ALLOWED_ROLES", f"user{SEP}admin").split(SEP) + for role in os.environ.get( + "OAUTH_ALLOWED_ROLES", f"user{OAUTH_ROLES_SEPARATOR}admin" + ).split(OAUTH_ROLES_SEPARATOR) if role ], ) @@ -600,7 +602,9 @@ OAUTH_ADMIN_ROLES = PersistentConfig( "oauth.admin_roles", [ role.strip() - for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(SEP) + for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split( + OAUTH_ROLES_SEPARATOR + ) if role ], ) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 77a2ebd46e..b5c5944683 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -43,6 +43,7 @@ from open_webui.config import ( ENABLE_OAUTH_GROUP_CREATION, OAUTH_BLOCKED_GROUPS, OAUTH_GROUPS_SEPARATOR, + OAUTH_ROLES_SEPARATOR, OAUTH_ROLES_CLAIM, OAUTH_SUB_CLAIM, OAUTH_GROUPS_CLAIM, @@ -1032,7 +1033,13 @@ class OAuthManager: if isinstance(claim_data, list): oauth_roles = claim_data - if isinstance(claim_data, str) or isinstance(claim_data, int): + elif isinstance(claim_data, str): + # Split by the configured separator if present + if OAUTH_ROLES_SEPARATOR and OAUTH_ROLES_SEPARATOR in claim_data: + oauth_roles = claim_data.split(OAUTH_ROLES_SEPARATOR) + else: + oauth_roles = [claim_data] + elif isinstance(claim_data, int): oauth_roles = [str(claim_data)] log.debug(f"Oauth Roles claim: {oauth_claim}") From fa0efae4d517c5cf488acbd5c233c772c6fd5740 Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Thu, 27 Nov 2025 06:42:56 +0800 Subject: [PATCH 14/43] i18n: improve Chinese translation (#19497) --- src/lib/i18n/locales/zh-CN/translation.json | 10 +++++----- src/lib/i18n/locales/zh-TW/translation.json | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/i18n/locales/zh-CN/translation.json b/src/lib/i18n/locales/zh-CN/translation.json index 05c79bafdc..a6cd010500 100644 --- a/src/lib/i18n/locales/zh-CN/translation.json +++ b/src/lib/i18n/locales/zh-CN/translation.json @@ -152,7 +152,7 @@ "Ask": "提问", "Ask a question": "提问", "Assistant": "助手", - "Async Embedding Processing": "", + "Async Embedding Processing": "异步嵌入处理", "Attach File From Knowledge": "引用知识库中的文件", "Attach Knowledge": "引用知识库", "Attach Notes": "引用笔记", @@ -558,7 +558,7 @@ "Enter Datalab Marker API Base URL": "输入 Datalab Marker 接口地址", "Enter Datalab Marker API Key": "输入 Datalab Marker 接口密钥", "Enter description": "输入简介描述", - "Enter Docling API Key": "", + "Enter Docling API Key": "输入 Docling 接口密钥", "Enter Docling Server URL": "输入 Docling 服务器接口地址", "Enter Document Intelligence Endpoint": "输入 Document Intelligence 端点", "Enter Document Intelligence Key": "输入 Document Intelligence 密钥", @@ -573,7 +573,7 @@ "Enter Firecrawl API Base URL": "输入 Firecrawl 接口地址", "Enter Firecrawl API Key": "输入 Firecrawl 接口密钥", "Enter folder name": "输入分组名称", - "Enter function name filter list (e.g. func1, !func2)": "", + "Enter function name filter list (e.g. func1, !func2)": "输入函数名称过滤列表(例如:func1, !func2)", "Enter Github Raw URL": "输入 Github Raw 链接", "Enter Google PSE API Key": "输入 Google PSE 接口密钥", "Enter Google PSE Engine Id": "输入 Google PSE 引擎 ID", @@ -785,7 +785,7 @@ "Function is now globally disabled": "函数全局已禁用", "Function is now globally enabled": "函数全局已启用", "Function Name": "函数名称", - "Function Name Filter List": "", + "Function Name Filter List": "函数名称过滤列表", "Function updated successfully": "函数更新成功", "Functions": "函数", "Functions allow arbitrary code execution.": "注意:函数有权执行任意代码", @@ -1350,7 +1350,7 @@ "Run": "运行", "Running": "运行中", "Running...": "运行中...", - "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "", + "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "并行运行嵌入任务以加快处理速度。如果遇到限速问题,请关闭此选项。", "Save": "保存", "Save & Create": "保存并创建", "Save & Update": "保存并更新", diff --git a/src/lib/i18n/locales/zh-TW/translation.json b/src/lib/i18n/locales/zh-TW/translation.json index 584b30377c..b5baa7e459 100644 --- a/src/lib/i18n/locales/zh-TW/translation.json +++ b/src/lib/i18n/locales/zh-TW/translation.json @@ -152,7 +152,7 @@ "Ask": "提問", "Ask a question": "提出問題", "Assistant": "助理", - "Async Embedding Processing": "", + "Async Embedding Processing": "異步嵌入處理", "Attach File From Knowledge": "從知識庫附加檔案", "Attach Knowledge": "附加知識庫", "Attach Notes": "附加筆記", @@ -558,7 +558,7 @@ "Enter Datalab Marker API Base URL": "輸入 Datalab Marker API 請求 URL", "Enter Datalab Marker API Key": "輸入 Datalab Marker API 金鑰", "Enter description": "輸入描述", - "Enter Docling API Key": "", + "Enter Docling API Key": "輸入 Docling API 金鑰", "Enter Docling Server URL": "請輸入 Docling 伺服器 URL", "Enter Document Intelligence Endpoint": "輸入 Document Intelligence 端點", "Enter Document Intelligence Key": "輸入 Document Intelligence 金鑰", @@ -573,7 +573,7 @@ "Enter Firecrawl API Base URL": "輸入 Firecrawl API 基底 URL", "Enter Firecrawl API Key": "輸入 Firecrawl API 金鑰", "Enter folder name": "輸入分組名稱", - "Enter function name filter list (e.g. func1, !func2)": "", + "Enter function name filter list (e.g. func1, !func2)": "輸入函式名稱篩選列表(例如:func1, !func2)", "Enter Github Raw URL": "輸入 GitHub Raw URL", "Enter Google PSE API Key": "輸入 Google PSE API 金鑰", "Enter Google PSE Engine Id": "輸入 Google PSE 引擎 ID", @@ -785,7 +785,7 @@ "Function is now globally disabled": "已全域停用函式", "Function is now globally enabled": "已全域啟用函式", "Function Name": "函式名稱", - "Function Name Filter List": "", + "Function Name Filter List": "函式名稱篩選列表", "Function updated successfully": "成功更新函式", "Functions": "函式", "Functions allow arbitrary code execution.": "函式允許執行任意程式碼。", @@ -1350,7 +1350,7 @@ "Run": "執行", "Running": "正在執行", "Running...": "正在執行...", - "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "", + "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "同時執行嵌入任務以加快處理速度。如果遇到速率限制問題,請關閉此功能。", "Save": "儲存", "Save & Create": "儲存並建立", "Save & Update": "儲存並更新", From 9f89cc5adc8576703ecf68282b3d62614213f193 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 26 Nov 2025 21:28:32 -0500 Subject: [PATCH 15/43] refac --- src/lib/components/admin/Users/UserList.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 181e5c6a9d..a76234643d 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -96,11 +96,7 @@ } }; - $: if (page) { - getUserList(); - } - - $: if (query !== null && orderBy && direction) { + $: if (query !== null && page !== null && orderBy !== null && direction !== null) { getUserList(); } From d1bbf6ba9225fa41f3638a158c52673c37b52e9c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 26 Nov 2025 21:29:52 -0500 Subject: [PATCH 16/43] refac --- src/lib/components/workspace/Models.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index fa727bfeb5..33c7b31cdf 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -214,8 +214,6 @@ viewOption = localStorage.workspaceViewOption ?? ''; page = 1; - await getModelList(); - let groups = await getGroups(localStorage.token); groupIds = groups.map((group) => group.id); From 3fe5a47050b2c45fe7485d61bd2eddc58c988d60 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 26 Nov 2025 21:33:27 -0500 Subject: [PATCH 17/43] refac/enh: knowledge base name on icon hover --- .../components/chat/MessageInput/Commands/Knowledge.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte index 3c8b9e49fd..77e5c16804 100644 --- a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte @@ -121,6 +121,7 @@ ...item, type: 'collection' })); + let collection_files = knowledge.length > 0 ? [ @@ -139,7 +140,7 @@ .map((file) => ({ ...file, name: file?.meta?.name, - description: `${file?.collection?.name} - ${file?.collection?.description}`, + description: `${file?.collection?.description}`, knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE type: 'file' })) @@ -218,7 +219,7 @@ content={item?.legacy ? $i18n.t('Legacy') : item?.type === 'file' - ? $i18n.t('File') + ? `${item?.collection?.name} > ${$i18n.t('File')}` : item?.type === 'collection' ? $i18n.t('Collection') : ''} From 384753c6cae0dec972e477ec7d94fa4849f52321 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 26 Nov 2025 21:47:20 -0500 Subject: [PATCH 18/43] refac/enh: drop profile_image_url field in responses --- backend/open_webui/models/auths.py | 12 ++---------- backend/open_webui/models/users.py | 16 ++++++++-------- backend/open_webui/routers/auths.py | 7 +++---- backend/open_webui/routers/users.py | 9 ++++----- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 39ff1cc7fb..0d0b881a78 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -3,7 +3,7 @@ import uuid from typing import Optional from open_webui.internal.db import Base, get_db -from open_webui.models.users import UserModel, Users +from open_webui.models.users import UserModel, UserProfileImageResponse, Users from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel from sqlalchemy import Boolean, Column, String, Text @@ -46,15 +46,7 @@ class ApiKey(BaseModel): api_key: Optional[str] = None -class UserResponse(BaseModel): - id: str - email: str - name: str - role: str - profile_image_url: str - - -class SigninResponse(Token, UserResponse): +class SigninResponse(Token, UserProfileImageResponse): pass diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index d93f7ddeb3..5809a7124f 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -135,18 +135,18 @@ class UserIdNameListResponse(BaseModel): total: int -class UserResponse(BaseModel): - id: str - name: str - email: str - role: str - profile_image_url: str - - class UserNameResponse(BaseModel): id: str name: str role: str + + +class UserResponse(UserNameResponse): + email: str + + +class UserProfileImageResponse(UserNameResponse): + email: str profile_image_url: str diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 764196c5f1..2a73496f3b 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -16,9 +16,8 @@ from open_webui.models.auths import ( SigninResponse, SignupForm, UpdatePasswordForm, - UserResponse, ) -from open_webui.models.users import Users, UpdateProfileForm +from open_webui.models.users import UserProfileImageResponse, Users, UpdateProfileForm from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions @@ -78,7 +77,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"]) ############################ -class SessionUserResponse(Token, UserResponse): +class SessionUserResponse(Token, UserProfileImageResponse): expires_at: Optional[int] = None permissions: Optional[dict] = None @@ -149,7 +148,7 @@ async def get_session_user( ############################ -@router.post("/update/profile", response_model=UserResponse) +@router.post("/update/profile", response_model=UserProfileImageResponse) async def update_profile( form_data: UpdateProfileForm, session_user=Depends(get_verified_user) ): diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index f9e1c220a9..eddc56d77a 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -359,14 +359,14 @@ async def update_user_info_by_session_user( ############################ -class UserResponse(BaseModel): +class UserActiveResponse(BaseModel): name: str - profile_image_url: str + profile_image_url: Optional[str] = None active: Optional[bool] = None model_config = ConfigDict(extra="allow") -@router.get("/{user_id}", response_model=UserResponse) +@router.get("/{user_id}", response_model=UserActiveResponse) async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): # Check if user_id is a shared chat # If it is, get the user_id from the chat @@ -384,11 +384,10 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): user = Users.get_user_by_id(user_id) if user: - return UserResponse( + return UserActiveResponse( **{ "id": user.id, "name": user.name, - "profile_image_url": user.profile_image_url, "active": get_active_status_by_user_id(user_id), } ) From 04b337323a79aac6a901c4d3d7b493e8e223a0f2 Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Thu, 27 Nov 2025 03:48:06 +0100 Subject: [PATCH 19/43] fix: correct role check on OAuth login (#19476) When a users role is switched from admin to user in the OAuth provider their groups are not correctly updated when ENABLE_OAUTH_GROUP_MANAGEMENT is enabled. --- backend/open_webui/utils/oauth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index b5c5944683..f8a924e8d0 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1408,6 +1408,9 @@ class OAuthManager: determined_role = self.get_user_role(user, user_data) if user.role != determined_role: Users.update_user_role_by_id(user.id, determined_role) + # Update the user object in memory as well, + # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below + user.role = determined_role # Update profile picture if enabled and different from current if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM From 457af65df6439bac90dcdaa1231599da4f06874e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 26 Nov 2025 22:47:48 -0500 Subject: [PATCH 20/43] enh/feat: toggle folders & user perm --- backend/open_webui/config.py | 15 ++++++++++++++- backend/open_webui/main.py | 3 +++ backend/open_webui/routers/auths.py | 4 ++++ backend/open_webui/routers/folders.py | 18 +++++++++++++++++- backend/open_webui/routers/users.py | 4 +++- .../components/admin/Settings/General.svelte | 8 ++++++++ .../admin/Users/Groups/EditGroupModal.svelte | 5 +++-- .../admin/Users/Groups/Permissions.svelte | 5 +++-- src/lib/components/layout/Sidebar.svelte | 8 ++++++-- 9 files changed, 61 insertions(+), 9 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index ec62c8ba01..54ca0218d7 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1447,6 +1447,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = ( == "true" ) +USER_PERMISSIONS_FEATURES_FOLDERS = ( + os.environ.get("USER_PERMISSIONS_FEATURES_FOLDERS", "True").lower() == "true" +) + USER_PERMISSIONS_FEATURES_NOTES = ( os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" ) @@ -1503,12 +1507,15 @@ DEFAULT_USER_PERMISSIONS = { "temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, }, "features": { + # General features "api_keys": USER_PERMISSIONS_FEATURES_API_KEYS, + "folders": USER_PERMISSIONS_FEATURES_FOLDERS, + "notes": USER_PERMISSIONS_FEATURES_NOTES, "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, + # Chat features "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, - "notes": USER_PERMISSIONS_FEATURES_NOTES, }, } @@ -1518,6 +1525,12 @@ USER_PERMISSIONS = PersistentConfig( DEFAULT_USER_PERMISSIONS, ) +ENABLE_FOLDERS = PersistentConfig( + "ENABLE_FOLDERS", + "folders.enable", + os.environ.get("ENABLE_FOLDERS", "True").lower() == "true", +) + ENABLE_CHANNELS = PersistentConfig( "ENABLE_CHANNELS", "channels.enable", diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index af8e670a53..899a3e08e1 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -352,6 +352,7 @@ from open_webui.config import ( ENABLE_API_KEYS, ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, API_KEYS_ALLOWED_ENDPOINTS, + ENABLE_FOLDERS, ENABLE_CHANNELS, ENABLE_NOTES, ENABLE_COMMUNITY_SHARING, @@ -767,6 +768,7 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL app.state.config.BANNERS = WEBUI_BANNERS +app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING @@ -1842,6 +1844,7 @@ async def get_app_config(request: Request): **( { "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, + "enable_folders": app.state.config.ENABLE_FOLDERS, "enable_channels": app.state.config.ENABLE_CHANNELS, "enable_notes": app.state.config.ENABLE_NOTES, "enable_web_search": app.state.config.ENABLE_WEB_SEARCH, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 2a73496f3b..24cbd9a03f 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -900,6 +900,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, @@ -921,6 +922,7 @@ class AdminConfig(BaseModel): JWT_EXPIRES_IN: str ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool + ENABLE_FOLDERS: bool ENABLE_CHANNELS: bool ENABLE_NOTES: bool ENABLE_USER_WEBHOOKS: bool @@ -945,6 +947,7 @@ async def update_admin_config( form_data.API_KEYS_ALLOWED_ENDPOINTS ) + request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES @@ -987,6 +990,7 @@ async def update_admin_config( "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 03212bdb7c..fe2bf367bf 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -46,7 +46,23 @@ router = APIRouter() @router.get("/", response_model=list[FolderNameIdResponse]) -async def get_folders(user=Depends(get_verified_user)): +async def get_folders(request: Request, user=Depends(get_verified_user)): + if request.app.state.config.ENABLE_FOLDERS is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if user.role != "admin" and not has_permission( + user.id, + "features.folders", + request.app.state.config.USER_PERMISSIONS, + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + folders = Folders.get_folders_by_user_id(user.id) # Verify folder data integrity diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index eddc56d77a..9b30ba8f20 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -219,11 +219,13 @@ class ChatPermissions(BaseModel): class FeaturesPermissions(BaseModel): api_keys: bool = False + folders: bool = True + notes: bool = True direct_tool_servers: bool = False + web_search: bool = True image_generation: bool = True code_interpreter: bool = True - notes: bool = True class UserPermissions(BaseModel): diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index a7b62857c0..d46f37a89f 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -676,6 +676,14 @@ +
+
+ {$i18n.t('Folders')} +
+ + +
+
{$i18n.t('Notes')} ({$i18n.t('Beta')}) diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index ef97294c96..d105c75d50 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -84,11 +84,12 @@ }, features: { api_keys: false, + folders: true, + notes: true, direct_tool_servers: false, web_search: true, image_generation: true, - code_interpreter: true, - notes: true + code_interpreter: true } }; diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 9f0e4ef2e9..892fc6fb03 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -54,11 +54,12 @@ }, features: { api_keys: false, + folders: true, + notes: true, direct_tool_servers: false, web_search: true, image_generation: true, - code_interpreter: true, - notes: true + code_interpreter: true } }; diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index ad534dab0d..42e2ad8ded 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -63,6 +63,7 @@ import Note from '../icons/Note.svelte'; import { slide } from 'svelte/transition'; import HotkeyHint from '../common/HotkeyHint.svelte'; + import { key } from 'vega'; const BREAKPOINT = 768; @@ -90,8 +91,11 @@ } const initFolders = async () => { + if ($config?.features?.enable_folders === false) { + return; + } + const folderList = await getFolders(localStorage.token).catch((error) => { - toast.error(`${error}`); return []; }); _folders.set(folderList.sort((a, b) => b.updated_at - a.updated_at)); @@ -932,7 +936,7 @@ {/if} - {#if folders} + {#if $config?.features?.enable_folders && ($user?.role === 'admin' || ($user?.permissions?.features?.folders ?? true)) && Object.keys(folders).length > 0} Date: Wed, 26 Nov 2025 23:54:55 -0500 Subject: [PATCH 21/43] refac --- src/routes/(app)/admin/users/+page.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte index 8a8e6be79f..3f20096169 100644 --- a/src/routes/(app)/admin/users/+page.svelte +++ b/src/routes/(app)/admin/users/+page.svelte @@ -2,11 +2,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; - import Users from '$lib/components/admin/Users.svelte'; - - onMount(() => { - goto('/admin/users/overview'); + onMount(async () => { + await goto('/admin/users/overview'); }); - - From 86cdcda29ad2eeae98894cb32bbc3b013860fceb Mon Sep 17 00:00:00 2001 From: stevessr <89645372+stevessr@users.noreply.github.com> Date: Thu, 27 Nov 2025 13:01:36 +0800 Subject: [PATCH 22/43] fix: button without type (#19534) --- src/lib/components/chat/MessageInput.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 93ca4e4d04..f79e370d1e 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1480,6 +1480,7 @@
- - {#if $user?.role === 'admin'} - diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9408604da6..5c1080cfea 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -28,7 +28,8 @@ isApp, appInfo, toolServers, - playingNotificationSound + playingNotificationSound, + channels } from '$lib/stores'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; @@ -483,6 +484,23 @@ const type = event?.data?.type ?? null; const data = event?.data?.data ?? null; + if ($channels) { + channels.set( + $channels.map((ch) => { + if (ch.id === event.channel_id) { + if (type === 'message') { + return { + ...ch, + unread_count: (ch.unread_count ?? 0) + 1, + last_message_at: event.created_at + }; + } + } + return ch; + }) + ); + } + if (type === 'message') { if ($isLastActiveTab) { if ($settings?.notificationEnabled ?? false) { From 28659f6af5dd569e950caa4ce289651a8c95ae80 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 04:35:12 -0500 Subject: [PATCH 27/43] refac/fix: files batch/add endpoint --- backend/open_webui/routers/knowledge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index ad47fc1686..654f11588a 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -708,7 +708,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse]) -def add_files_to_knowledge_batch( +async def add_files_to_knowledge_batch( request: Request, id: str, form_data: list[KnowledgeFileIdForm], @@ -748,7 +748,7 @@ def add_files_to_knowledge_batch( # Process files try: - result = process_files_batch( + result = await process_files_batch( request=request, form_data=BatchProcessFilesForm(files=files, collection_name=id), user=user, From 09b6ea38c579659f8ca43ae5ea3746df3ac561ad Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 04:44:01 -0500 Subject: [PATCH 28/43] feat/enh: group export endpoint --- backend/open_webui/routers/groups.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 2b531b462b..b68db3a15e 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -106,6 +106,32 @@ async def get_group_by_id(id: str, user=Depends(get_admin_user)): ) +############################ +# ExportGroupById +############################ + + +class GroupExportResponse(GroupResponse): + user_ids: list[str] = [] + pass + + +@router.get("/id/{id}/export", response_model=Optional[GroupExportResponse]) +async def export_group_by_id(id: str, user=Depends(get_admin_user)): + group = Groups.get_group_by_id(id) + if group: + return GroupExportResponse( + **group.model_dump(), + member_count=Groups.get_group_member_count_by_id(group.id), + user_ids=Groups.get_group_user_ids_by_id(group.id), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + ############################ # UpdateGroupById ############################ From 421aba7cd7cd708168b1f2565026c74525a67905 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 04:49:29 -0500 Subject: [PATCH 29/43] refac: hide channel add button for users --- src/lib/components/layout/Sidebar.svelte | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 42e2ad8ded..70ff87eab8 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -914,15 +914,15 @@ name={$i18n.t('Channels')} chevron={false} dragAndDrop={false} - onAdd={async () => { - if ($user?.role === 'admin') { - await tick(); + onAdd={$user?.role === 'admin' + ? async () => { + await tick(); - setTimeout(() => { - showCreateChannel = true; - }, 0); - } - }} + setTimeout(() => { + showCreateChannel = true; + }, 0); + } + : null} onAddLabel={$i18n.t('Create Channel')} > {#each $channels as channel} From 7a374ca2a5474f4ed9a8256ff4831f395d4354db Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 05:11:17 -0500 Subject: [PATCH 30/43] refac --- src/lib/components/layout/Sidebar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 70ff87eab8..2197859311 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -936,7 +936,7 @@ {/if} - {#if $config?.features?.enable_folders && ($user?.role === 'admin' || ($user?.permissions?.features?.folders ?? true)) && Object.keys(folders).length > 0} + {#if $config?.features?.enable_folders && ($user?.role === 'admin' || ($user?.permissions?.features?.folders ?? true))} Date: Thu, 27 Nov 2025 05:25:38 -0500 Subject: [PATCH 31/43] refac --- src/lib/components/channel/ChannelInfoModal/UserList.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte index a38ad352f9..7aaee07829 100644 --- a/src/lib/components/channel/ChannelInfoModal/UserList.svelte +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -79,11 +79,7 @@ } }; - $: if (page) { - getUserList(); - } - - $: if (query !== null && orderBy && direction) { + $: if (page !== null && query !== null && orderBy !== null && direction !== null) { getUserList(); } From f2c56fc839b6547fbcfea7b2436a207498941d26 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 06:03:22 -0500 Subject: [PATCH 32/43] refac --- src/lib/components/common/Checkbox.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/common/Checkbox.svelte b/src/lib/components/common/Checkbox.svelte index feae33cd25..785302fb8e 100644 --- a/src/lib/components/common/Checkbox.svelte +++ b/src/lib/components/common/Checkbox.svelte @@ -6,6 +6,8 @@ export let indeterminate = false; export let disabled = false; + export let disabledClassName = 'opacity-50 cursor-not-allowed'; + let _state = 'unchecked'; $: _state = state; @@ -16,7 +18,7 @@ 'unchecked' ? 'bg-black outline-black ' : 'hover:outline-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'} text-white transition-all rounded-sm inline-block w-3.5 h-3.5 relative {disabled - ? 'opacity-50 cursor-not-allowed' + ? disabledClassName : ''}" on:click={() => { if (disabled) return; From acccb9afdd557274d6296c70258bb897bbb6652f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:27:32 -0500 Subject: [PATCH 33/43] feat: dm channels --- backend/open_webui/models/channels.py | 108 ++++- backend/open_webui/routers/channels.py | 410 +++++++++++++----- src/lib/apis/channels/index.ts | 42 +- .../admin/Users/Groups/Users.svelte | 2 +- .../channel/ChannelInfoModal.svelte | 34 +- .../channel/ChannelInfoModal/UserList.svelte | 60 +-- src/lib/components/channel/Messages.svelte | 27 +- src/lib/components/channel/Navbar.svelte | 41 +- src/lib/components/layout/Sidebar.svelte | 35 +- .../layout/Sidebar/ChannelItem.svelte | 108 ++++- .../layout/Sidebar/ChannelModal.svelte | 50 ++- .../workspace/common/UserListSelector.svelte | 253 +++++++++++ src/routes/+layout.svelte | 35 +- 13 files changed, 989 insertions(+), 216 deletions(-) create mode 100644 src/lib/components/workspace/common/UserListSelector.svelte diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 325ce10143..a78c3b05e0 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -113,22 +113,24 @@ class ChannelResponse(ChannelModel): class ChannelForm(BaseModel): + type: Optional[str] = None name: str description: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None + user_ids: Optional[list[str]] = None class ChannelTable: def insert_new_channel( - self, type: Optional[str], form_data: ChannelForm, user_id: str + self, form_data: ChannelForm, user_id: str ) -> Optional[ChannelModel]: with get_db() as db: channel = ChannelModel( **{ **form_data.model_dump(), - "type": type, + "type": form_data.type if form_data.type else None, "name": form_data.name.lower(), "id": str(uuid.uuid4()), "user_id": user_id, @@ -136,9 +138,34 @@ class ChannelTable: "updated_at": int(time.time_ns()), } ) - new_channel = Channel(**channel.model_dump()) + if form_data.type == "dm": + # For direct message channels, automatically add the specified users as members + user_ids = form_data.user_ids or [] + if user_id not in user_ids: + user_ids.append(user_id) # Ensure the creator is also a member + + for uid in user_ids: + channel_member = ChannelMemberModel( + **{ + "id": str(uuid.uuid4()), + "channel_id": channel.id, + "user_id": uid, + "status": "joined", + "is_active": True, + "is_channel_muted": False, + "is_channel_pinned": False, + "joined_at": int(time.time_ns()), + "left_at": None, + "last_read_at": int(time.time_ns()), + "created_at": int(time.time_ns()), + "updated_at": int(time.time_ns()), + } + ) + new_membership = ChannelMember(**channel_member.model_dump()) + db.add(new_membership) + db.add(new_channel) db.commit() return channel @@ -152,12 +179,41 @@ class ChannelTable: self, user_id: str, permission: str = "read" ) -> list[ChannelModel]: channels = self.get_channels() - return [ - channel - for channel in channels - if channel.user_id == user_id - or has_access(user_id, permission, channel.access_control) - ] + + channel_list = [] + for channel in channels: + if channel.type == "dm": + membership = self.get_member_by_channel_and_user_id(channel.id, user_id) + if membership and membership.is_active: + channel_list.append(channel) + else: + if channel.user_id == user_id or has_access( + user_id, permission, channel.access_control + ): + channel_list.append(channel) + + return channel_list + + def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]: + with get_db() as db: + subquery = ( + db.query(ChannelMember.channel_id) + .filter(ChannelMember.user_id.in_(user_ids)) + .group_by(ChannelMember.channel_id) + .having(func.count(ChannelMember.user_id) == len(user_ids)) + .subquery() + ) + + channel = ( + db.query(Channel) + .filter( + Channel.id.in_(subquery), + Channel.type == "dm", + ) + .first() + ) + + return ChannelModel.model_validate(channel) if channel else None def join_channel( self, channel_id: str, user_id: str @@ -233,6 +289,18 @@ class ChannelTable: ) return ChannelMemberModel.model_validate(membership) if membership else None + def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]: + with get_db() as db: + memberships = ( + db.query(ChannelMember) + .filter(ChannelMember.channel_id == channel_id) + .all() + ) + return [ + ChannelMemberModel.model_validate(membership) + for membership in memberships + ] + def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool: with get_db() as db: membership = ( @@ -271,6 +339,27 @@ class ChannelTable: db.commit() return True + def update_member_active_status( + self, channel_id: str, user_id: str, is_active: bool + ) -> bool: + with get_db() as db: + membership = ( + db.query(ChannelMember) + .filter( + ChannelMember.channel_id == channel_id, + ChannelMember.user_id == user_id, + ) + .first() + ) + if not membership: + return False + + membership.is_active = is_active + membership.updated_at = int(time.time_ns()) + + db.commit() + return True + def is_user_channel_member(self, channel_id: str, user_id: str) -> bool: with get_db() as db: membership = ( @@ -278,7 +367,6 @@ class ChannelTable: .filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, - ChannelMember.is_active == True, ) .first() ) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index e720648a7d..2167e87d47 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -13,6 +13,7 @@ from open_webui.socket.main import ( get_active_status_by_user_id, ) from open_webui.models.users import ( + UserIdNameResponse, UserListResponse, UserModelResponse, Users, @@ -66,6 +67,9 @@ router = APIRouter() class ChannelListItemResponse(ChannelModel): + user_ids: Optional[list[str]] = None # 'dm' channels only + users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + last_message_at: Optional[int] = None # timestamp in epoch (time_ns) unread_count: int = 0 @@ -85,9 +89,23 @@ async def get_channels(user=Depends(get_verified_user)): channel.id, user.id, channel_member.last_read_at if channel_member else None ) + user_ids = None + users = None + if channel.type == "dm": + user_ids = [ + member.user_id + for member in Channels.get_members_by_channel_id(channel.id) + ] + users = [ + UserIdNameResponse(**user.model_dump()) + for user in Users.get_users_by_user_ids(user_ids) + ] + channel_list.append( ChannelListItemResponse( **channel.model_dump(), + user_ids=user_ids, + users=users, last_message_at=last_message_at, unread_count=unread_count, ) @@ -111,7 +129,15 @@ async def get_all_channels(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[ChannelModel]) async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)): try: - channel = Channels.insert_new_channel(None, form_data, user.id) + if form_data.type == "dm" and len(form_data.user_ids) == 1: + existing_channel = Channels.get_dm_channel_by_user_ids( + [user.id, form_data.user_ids[0]] + ) + if existing_channel: + Channels.update_member_active_status(existing_channel.id, user.id, True) + return ChannelModel(**existing_channel.model_dump()) + + channel = Channels.insert_new_channel(form_data, user.id) return ChannelModel(**channel.model_dump()) except Exception as e: log.exception(e) @@ -125,7 +151,15 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user ############################ -@router.get("/{id}", response_model=Optional[ChannelResponse]) +class ChannelFullResponse(ChannelResponse): + user_ids: Optional[list[str]] = None # 'dm' channels only + users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + + last_read_at: Optional[int] = None # timestamp in epoch (time_ns) + unread_count: int = 0 + + +@router.get("/{id}", response_model=Optional[ChannelFullResponse]) async def get_channel_by_id(id: str, user=Depends(get_verified_user)): channel = Channels.get_channel_by_id(id) if not channel: @@ -133,33 +167,82 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + user_ids = None + users = None + + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + user_ids = [ + member.user_id for member in Channels.get_members_by_channel_id(channel.id) + ] + users = [ + UserIdNameResponse(**user.model_dump()) + for user in Users.get_users_by_user_ids(user_ids) + ] + + channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) + unread_count = Messages.get_unread_message_count( + channel.id, user.id, channel_member.last_read_at if channel_member else None ) - write_access = has_access( - user.id, type="write", access_control=channel.access_control, strict=False - ) + return ChannelFullResponse( + **{ + **channel.model_dump(), + "user_ids": user_ids, + "users": users, + "write_access": True, + "user_count": len(user_ids), + "last_read_at": channel_member.last_read_at if channel_member else None, + "unread_count": unread_count, + } + ) - user_count = len(get_users_with_access("read", channel.access_control)) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) - return ChannelResponse( - **{ - **channel.model_dump(), - "write_access": write_access or user.role == "admin", - "user_count": user_count, - } - ) + write_access = has_access( + user.id, type="write", access_control=channel.access_control, strict=False + ) + + user_count = len(get_users_with_access("read", channel.access_control)) + + channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) + unread_count = Messages.get_unread_message_count( + channel.id, user.id, channel_member.last_read_at if channel_member else None + ) + + return ChannelFullResponse( + **{ + **channel.model_dump(), + "user_ids": user_ids, + "users": users, + "write_access": write_access or user.role == "admin", + "user_count": user_count, + "last_read_at": channel_member.last_read_at if channel_member else None, + "unread_count": unread_count, + } + ) + + +############################ +# GetChannelMembersById +############################ PAGE_ITEM_COUNT = 30 -@router.get("/{id}/users", response_model=UserListResponse) -async def get_channel_users_by_id( +@router.get("/{id}/members", response_model=UserListResponse) +async def get_channel_members_by_id( id: str, query: Optional[str] = None, order_by: Optional[str] = None, @@ -179,36 +262,90 @@ async def get_channel_users_by_id( page = max(1, page) skip = (page - 1) * limit - filter = { - "roles": ["!pending"], - } - - if query: - filter["query"] = query - if order_by: - filter["order_by"] = order_by - if direction: - filter["direction"] = direction - - permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control) - if permitted_ids: - filter["user_ids"] = permitted_ids.get("user_ids") - filter["group_ids"] = permitted_ids.get("group_ids") - - result = Users.get_users(filter=filter, skip=skip, limit=limit) - - users = result["users"] - total = result["total"] - - return { - "users": [ - UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - for user in users - ], - "total": total, - } + + user_ids = [ + member.user_id for member in Channels.get_members_by_channel_id(channel.id) + ] + users = Users.get_users_by_user_ids(user_ids) + + total = len(users) + + return { + "users": [ + UserModelResponse( + **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + ) + for user in users + ], + "total": total, + } + + else: + filter = { + "roles": ["!pending"], + } + + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control) + if permitted_ids: + filter["user_ids"] = permitted_ids.get("user_ids") + filter["group_ids"] = permitted_ids.get("group_ids") + + result = Users.get_users(filter=filter, skip=skip, limit=limit) + + users = result["users"] + total = result["total"] + + return { + "users": [ + UserModelResponse( + **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + ) + for user in users + ], + "total": total, + } + + +################################################# +# UpdateIsActiveMemberByIdAndUserId +################################################# + + +class UpdateActiveMemberForm(BaseModel): + is_active: bool + + +@router.post("/{id}/members/active", response_model=bool) +async def update_is_active_member_by_id_and_user_id( + id: str, + form_data: UpdateActiveMemberForm, + user=Depends(get_verified_user), +): + channel = Channels.get_channel_by_id(id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + Channels.update_member_active_status(channel.id, user.id, form_data.is_active) + return True ############################ @@ -278,16 +415,22 @@ async def get_channel_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) - channel_member = Channels.join_channel( - id, user.id - ) # Ensure user is a member of the channel + channel_member = Channels.join_channel( + id, user.id + ) # Ensure user is a member of the channel message_list = Messages.get_messages_by_channel_id(id, skip, limit) users = {} @@ -533,16 +676,30 @@ async def new_message_handler( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="write", access_control=channel.access_control, strict=False + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) try: message = Messages.insert_new_message(form_data, channel.id, user.id) if message: + if channel.type == "dm": + members = Channels.get_members_by_channel_id(channel.id) + for member in members: + if not member.is_active: + Channels.update_member_active_status( + channel.id, member.user_id, True + ) + message = Messages.get_message_by_id(message.id) event_data = { "channel_id": channel.id, @@ -641,12 +798,18 @@ async def get_channel_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) message = Messages.get_message_by_id(message_id) if not message: @@ -690,12 +853,18 @@ async def get_channel_thread_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit) users = {} @@ -749,14 +918,22 @@ async def update_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if ( - user.role != "admin" - and message.user_id != user.id - and not has_access(user.id, type="read", access_control=channel.access_control) - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if ( + user.role != "admin" + and message.user_id != user.id + and not has_access( + user.id, type="read", access_control=channel.access_control + ) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) try: message = Messages.update_message_by_id(message_id, form_data) @@ -805,12 +982,18 @@ async def add_reaction_to_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="write", access_control=channel.access_control, strict=False + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) message = Messages.get_message_by_id(message_id) if not message: @@ -868,12 +1051,18 @@ async def remove_reaction_by_id_and_user_id_and_name( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="write", access_control=channel.access_control, strict=False + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) message = Messages.get_message_by_id(message_id) if not message: @@ -945,16 +1134,25 @@ async def delete_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if ( - user.role != "admin" - and message.user_id != user.id - and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False - ) - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if ( + user.role != "admin" + and message.user_id != user.id + and not has_access( + user.id, + type="write", + access_control=channel.access_control, + strict=False, + ) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) try: Messages.delete_message_by_id(message_id) diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 2872bd89f8..5b510491fe 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -1,10 +1,12 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type ChannelForm = { + type?: string; name: string; data?: object; meta?: object; access_control?: object; + user_ids?: string[]; }; export const createNewChannel = async (token: string = '', channel: ChannelForm) => { @@ -101,7 +103,7 @@ export const getChannelById = async (token: string = '', channel_id: string) => return res; }; -export const getChannelUsersById = async ( +export const getChannelMembersById = async ( token: string, channel_id: string, query?: string, @@ -129,7 +131,7 @@ export const getChannelUsersById = async ( } res = await fetch( - `${WEBUI_API_BASE_URL}/channels/${channel_id}/users?${searchParams.toString()}`, + `${WEBUI_API_BASE_URL}/channels/${channel_id}/members?${searchParams.toString()}`, { method: 'GET', headers: { @@ -155,6 +157,42 @@ export const getChannelUsersById = async ( return res; }; +export const updateChannelMemberActiveStatusById = async ( + token: string = '', + channel_id: string, + is_active: boolean +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/members/active`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ is_active }) + }) + .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 updateChannelById = async ( token: string = '', channel_id: string, diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index e017187677..ab544e5c8a 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -113,7 +113,7 @@ class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full" > - +
-
- {#if channel?.access_control === null} - - {:else} - - {/if} -
+ {#if channel?.type === 'dm'} +
+ {$i18n.t('Direct Message')} +
+ {:else} +
+ {#if channel?.access_control === null} + + {:else} + + {/if} +
-
- {channel.name} -
+
+ {channel.name} +
+ {/if}
diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte index 7aaee07829..b8fff44da3 100644 --- a/src/lib/components/channel/ChannelInfoModal/UserList.svelte +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -11,7 +11,7 @@ dayjs.extend(localizedFormat); import { toast } from 'svelte-sonner'; - import { getChannelUsersById } from '$lib/apis/channels'; + import { getChannelMembersById } from '$lib/apis/channels'; import Pagination from '$lib/components/common/Pagination.svelte'; import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte'; @@ -37,6 +37,8 @@ const i18n = getContext('i18n'); export let channel = null; + export let search = true; + export let sort = true; let page = 1; @@ -48,6 +50,10 @@ let direction = 'asc'; // default sort order const setSortKey = (key) => { + if (!sort) { + return; + } + if (orderBy === key) { direction = direction === 'asc' ? 'desc' : 'asc'; } else { @@ -58,7 +64,7 @@ const getUserList = async () => { try { - const res = await getChannelUsersById( + const res = await getChannelMembersById( localStorage.token, channel.id, query, @@ -90,31 +96,33 @@ {:else} -
-
-
-
- - - + {#if search} +
+
+
+
+ + + +
+
-
-
+ {/if} {#if users.length > 0}
@@ -123,9 +131,10 @@ class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200 w-full mb-0.5" >
-
- {/if}
+ + {#if channel?.type === 'dm'} + + {:else if $user?.role === 'admin'} + + {/if}
diff --git a/src/lib/components/layout/Sidebar/ChannelModal.svelte b/src/lib/components/layout/Sidebar/ChannelModal.svelte index 618f7508bf..e0d639624e 100644 --- a/src/lib/components/layout/Sidebar/ChannelModal.svelte +++ b/src/lib/components/layout/Sidebar/ChannelModal.svelte @@ -11,6 +11,7 @@ import { toast } from 'svelte-sonner'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; + import UserListSelector from '$lib/components/workspace/common/UserListSelector.svelte'; const i18n = getContext('i18n'); export let show = false; @@ -20,8 +21,11 @@ export let channel = null; export let edit = false; + let type = ''; let name = ''; + let accessControl = {}; + let userIds = []; let loading = false; @@ -32,16 +36,20 @@ const submitHandler = async () => { loading = true; await onSubmit({ + type: type, name: name.replace(/\s/g, '-'), - access_control: accessControl + access_control: accessControl, + user_ids: userIds }); show = false; loading = false; }; const init = () => { - name = channel.name; + type = channel?.type ?? ''; + name = channel?.name ?? ''; accessControl = channel.access_control; + userIds = channel?.user_ids ?? []; }; $: if (show) { @@ -74,8 +82,10 @@ }; const resetHandler = () => { + type = ''; name = ''; accessControl = {}; + userIds = []; loading = false; }; @@ -109,25 +119,49 @@ }} >
-
{$i18n.t('Channel Name')}
+
{$i18n.t('Channel Type')}
+ +
+ +
+
+ +
+
+ {$i18n.t('Channel Name')} + {type === 'dm' ? `${$i18n.t('Optional')}` : ''} +

-
-
- -
+
+ {#if type === 'dm'} + + {:else} +
+ +
+ {/if}
diff --git a/src/lib/components/workspace/common/UserListSelector.svelte b/src/lib/components/workspace/common/UserListSelector.svelte new file mode 100644 index 0000000000..0ff9877e62 --- /dev/null +++ b/src/lib/components/workspace/common/UserListSelector.svelte @@ -0,0 +1,253 @@ + + +
+ {#if users === null || total === null} +
+ +
+ {:else} + {#if userIds.length > 0} +
+
+ {userIds.length} + {$i18n.t('users')} +
+
+ {#each userIds as id} + {#if selectedUsers[id]} + + {/if} + {/each} +
+
+ {/if} + +
+
+
+
+ + + +
+ +
+
+
+ + {#if users.length > 0} +
+
+
+
+ + + +
+
+
+ {#each users as user, userIdx} + {#if user?.id !== $_user?.id} + + {/if} + {/each} +
+
+
+ + {#if pagination} + {#if total > 30} + + {/if} + {/if} + {:else} +
+ {$i18n.t('No users were found.')} +
+ {/if} + {/if} +
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c1080cfea..c6e1e3e946 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -56,6 +56,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import { getUserSettings } from '$lib/apis/users'; import dayjs from 'dayjs'; + import { getChannels } from '$lib/apis/channels'; const unregisterServiceWorkers = async () => { if ('serviceWorker' in navigator) { @@ -485,20 +486,28 @@ const data = event?.data?.data ?? null; if ($channels) { - channels.set( - $channels.map((ch) => { - if (ch.id === event.channel_id) { - if (type === 'message') { - return { - ...ch, - unread_count: (ch.unread_count ?? 0) + 1, - last_message_at: event.created_at - }; + if ($channels.find((ch) => ch.id === event.channel_id)) { + channels.set( + $channels.map((ch) => { + if (ch.id === event.channel_id) { + if (type === 'message') { + return { + ...ch, + unread_count: (ch.unread_count ?? 0) + 1, + last_message_at: event.created_at + }; + } } - } - return ch; - }) - ); + return ch; + }) + ); + } else { + await channels.set( + (await getChannels(localStorage.token)).sort((a, b) => + a.type === b.type ? 0 : a.type === 'dm' ? 1 : -1 + ) + ); + } } if (type === 'message') { From f1a7de94ba45236f04ddbc1552a2256dd103821a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:33:33 -0500 Subject: [PATCH 34/43] refac --- src/lib/components/channel/Channel.svelte | 19 ++++++++++++++++++- .../workspace/common/UserListSelector.svelte | 6 ------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index 419870937a..ff3a511b18 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -225,7 +225,24 @@ - #{channel?.name ?? 'Channel'} • Open WebUI + {#if channel?.type === 'dm'} + {channel?.name.trim() || + channel?.users.reduce((a, e, i, arr) => { + if (e.id === $user?.id) { + return a; + } + + if (a) { + return `${a}, ${e.name}`; + } else { + return e.name; + } + }, '')} • Open WebUI + {:else} + #{channel?.name ?? 'Channel'} • Open WebUI + {/if}
- -
From d5d0e72590fbd4df06ef644ce26d3faba9fe55df Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:39:00 -0500 Subject: [PATCH 35/43] refac --- backend/open_webui/routers/channels.py | 4 ++-- src/routes/+layout.svelte | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 2167e87d47..1bf905155e 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -129,9 +129,9 @@ async def get_all_channels(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[ChannelModel]) async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)): try: - if form_data.type == "dm" and len(form_data.user_ids) == 1: + if form_data.type == "dm": existing_channel = Channels.get_dm_channel_by_user_ids( - [user.id, form_data.user_ids[0]] + [user.id, *form_data.user_ids] ) if existing_channel: Channels.update_member_active_status(existing_channel.id, user.id, True) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c6e1e3e946..a42c975a62 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -511,9 +511,11 @@ } if (type === 'message') { + const title = `${data?.user?.name}${event?.channel?.type !== 'dm' ? ` (#${event?.channel?.name})` : ''}`; + if ($isLastActiveTab) { if ($settings?.notificationEnabled ?? false) { - new Notification(`${data?.user?.name} (#${event?.channel?.name}) • Open WebUI`, { + new Notification(`${title} • Open WebUI`, { body: data?.content, icon: `${WEBUI_API_BASE_URL}/users/${data?.user?.id}/profile/image` }); @@ -526,7 +528,7 @@ goto(`/channels/${event.channel_id}`); }, content: data?.content, - title: `#${event?.channel?.name}` + title: `${title}` }, duration: 15000, unstyled: true From 3b4d7d568b25d125730c3dea4c2fa645ff89dab6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:43:10 -0500 Subject: [PATCH 36/43] refac --- src/lib/components/channel/MessageInput/MentionList.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index f1863e6b45..4272a5650d 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -111,7 +111,9 @@ if (channelSuggestions) { // Add a dummy channel item _channels = [ - ...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c })) + ...$channels + .filter((c) => c?.type !== 'dm') + .map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c })) ]; } else { if (userSuggestions) { From d645cdbaf3ebb6e325855f8610fe314a6ac89c00 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:49:19 -0500 Subject: [PATCH 37/43] refac --- backend/open_webui/models/channels.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index a78c3b05e0..5d452b0216 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -7,7 +7,7 @@ from open_webui.internal.db import Base, get_db from open_webui.utils.access_control import has_access from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case from sqlalchemy import or_, func, select, and_, text from sqlalchemy.sql import exists @@ -196,11 +196,23 @@ class ChannelTable: def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]: with get_db() as db: + # Ensure uniqueness in case a list with duplicates is passed + unique_user_ids = list(set(user_ids)) + + match_count = func.sum( + case( + (ChannelMember.user_id.in_(unique_user_ids), 1), + else_=0, + ) + ) + subquery = ( db.query(ChannelMember.channel_id) - .filter(ChannelMember.user_id.in_(user_ids)) .group_by(ChannelMember.channel_id) - .having(func.count(ChannelMember.user_id) == len(user_ids)) + # 1. Channel must have exactly len(user_ids) members + .having(func.count(ChannelMember.user_id) == len(unique_user_ids)) + # 2. All those members must be in unique_user_ids + .having(match_count == len(unique_user_ids)) .subquery() ) From 6752772c1dd09ad6f23bfc99019baa4f601b9f3a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 07:57:31 -0500 Subject: [PATCH 38/43] chore: format --- src/lib/components/layout/Sidebar.svelte | 3 +++ src/lib/i18n/locales/ar-BH/translation.json | 4 ++++ src/lib/i18n/locales/ar/translation.json | 4 ++++ src/lib/i18n/locales/bg-BG/translation.json | 4 ++++ src/lib/i18n/locales/bn-BD/translation.json | 4 ++++ src/lib/i18n/locales/bo-TB/translation.json | 4 ++++ src/lib/i18n/locales/bs-BA/translation.json | 4 ++++ src/lib/i18n/locales/ca-ES/translation.json | 4 ++++ src/lib/i18n/locales/ceb-PH/translation.json | 4 ++++ src/lib/i18n/locales/cs-CZ/translation.json | 4 ++++ src/lib/i18n/locales/da-DK/translation.json | 4 ++++ src/lib/i18n/locales/de-DE/translation.json | 4 ++++ src/lib/i18n/locales/dg-DG/translation.json | 4 ++++ src/lib/i18n/locales/el-GR/translation.json | 4 ++++ src/lib/i18n/locales/en-GB/translation.json | 4 ++++ src/lib/i18n/locales/en-US/translation.json | 4 ++++ src/lib/i18n/locales/es-ES/translation.json | 4 ++++ src/lib/i18n/locales/et-EE/translation.json | 4 ++++ src/lib/i18n/locales/eu-ES/translation.json | 4 ++++ src/lib/i18n/locales/fa-IR/translation.json | 4 ++++ src/lib/i18n/locales/fi-FI/translation.json | 4 ++++ src/lib/i18n/locales/fr-CA/translation.json | 4 ++++ src/lib/i18n/locales/fr-FR/translation.json | 4 ++++ src/lib/i18n/locales/gl-ES/translation.json | 4 ++++ src/lib/i18n/locales/he-IL/translation.json | 4 ++++ src/lib/i18n/locales/hi-IN/translation.json | 4 ++++ src/lib/i18n/locales/hr-HR/translation.json | 4 ++++ src/lib/i18n/locales/hu-HU/translation.json | 4 ++++ src/lib/i18n/locales/id-ID/translation.json | 4 ++++ src/lib/i18n/locales/ie-GA/translation.json | 4 ++++ src/lib/i18n/locales/it-IT/translation.json | 4 ++++ src/lib/i18n/locales/ja-JP/translation.json | 4 ++++ src/lib/i18n/locales/ka-GE/translation.json | 4 ++++ src/lib/i18n/locales/kab-DZ/translation.json | 4 ++++ src/lib/i18n/locales/ko-KR/translation.json | 4 ++++ src/lib/i18n/locales/lt-LT/translation.json | 4 ++++ src/lib/i18n/locales/ms-MY/translation.json | 4 ++++ src/lib/i18n/locales/nb-NO/translation.json | 4 ++++ src/lib/i18n/locales/nl-NL/translation.json | 4 ++++ src/lib/i18n/locales/pa-IN/translation.json | 4 ++++ src/lib/i18n/locales/pl-PL/translation.json | 4 ++++ src/lib/i18n/locales/pt-BR/translation.json | 4 ++++ src/lib/i18n/locales/pt-PT/translation.json | 4 ++++ src/lib/i18n/locales/ro-RO/translation.json | 4 ++++ src/lib/i18n/locales/ru-RU/translation.json | 4 ++++ src/lib/i18n/locales/sk-SK/translation.json | 4 ++++ src/lib/i18n/locales/sr-RS/translation.json | 4 ++++ src/lib/i18n/locales/sv-SE/translation.json | 4 ++++ src/lib/i18n/locales/th-TH/translation.json | 4 ++++ src/lib/i18n/locales/tk-TM/translation.json | 4 ++++ src/lib/i18n/locales/tr-TR/translation.json | 4 ++++ src/lib/i18n/locales/ug-CN/translation.json | 4 ++++ src/lib/i18n/locales/uk-UA/translation.json | 4 ++++ src/lib/i18n/locales/ur-PK/translation.json | 4 ++++ src/lib/i18n/locales/uz-Cyrl-UZ/translation.json | 4 ++++ src/lib/i18n/locales/uz-Latn-Uz/translation.json | 4 ++++ src/lib/i18n/locales/vi-VN/translation.json | 4 ++++ src/lib/i18n/locales/zh-CN/translation.json | 4 ++++ src/lib/i18n/locales/zh-TW/translation.json | 4 ++++ 59 files changed, 235 insertions(+) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index e1ab6b505f..fb4a38b85d 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -733,6 +733,9 @@
{/if} + + + {#if $showSidebar}
Date: Thu, 27 Nov 2025 08:04:41 -0500 Subject: [PATCH 39/43] refac --- src/lib/components/channel/Messages/Message.svelte | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 8c050269e6..5379a7be00 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -252,14 +252,18 @@ {#if message.created_at} From 6bb204eb8075800934743bd435c2f7bb818798f2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 08:07:39 -0500 Subject: [PATCH 40/43] refac --- src/lib/components/channel/Channel.svelte | 4 +++- src/lib/stores/index.ts | 2 ++ src/routes/+layout.svelte | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index ff3a511b18..3caf18bcab 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -5,7 +5,7 @@ import { onDestroy, onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; - import { chatId, showSidebar, socket, user } from '$lib/stores'; + import { chatId, channelId as _channelId, showSidebar, socket, user } from '$lib/stores'; import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; import Messages from './Messages.svelte'; @@ -62,6 +62,7 @@ currentId = id; updateLastReadAt(id); + _channelId.set(id); top = false; messages = null; @@ -220,6 +221,7 @@ onDestroy(() => { // last read at updateLastReadAt(id); + _channelId.set(null); $socket?.off('events:channel', channelEventHandler); }); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index c4c2dc10c9..57257d59d3 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -51,6 +51,8 @@ export const chatId = writable(''); export const chatTitle = writable(''); export const channels = writable([]); +export const channelId = writable(null); + export const chats = writable(null); export const pinnedChats = writable([]); export const tags = writable([]); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a42c975a62..a153423909 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -29,7 +29,8 @@ appInfo, toolServers, playingNotificationSound, - channels + channels, + channelId } from '$lib/stores'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; @@ -486,7 +487,7 @@ const data = event?.data?.data ?? null; if ($channels) { - if ($channels.find((ch) => ch.id === event.channel_id)) { + if ($channels.find((ch) => ch.id === event.channel_id) && $channelId !== event.channel_id) { channels.set( $channels.map((ch) => { if (ch.id === event.channel_id) { From 289801b6089ca4f3e60d6c4bb6df3e7e570c1bbb Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 08:12:06 -0500 Subject: [PATCH 41/43] refac: styling --- src/lib/components/layout/Sidebar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index fb4a38b85d..c49135090e 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -953,7 +953,7 @@ /> {#if channelIdx < $channels.length - 1 && channel.type !== $channels[channelIdx + 1]?.type}
{/if} {/each} From ad86707605ce18db5cf18a1d1dcd0ce8103ff61a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 27 Nov 2025 08:20:14 -0500 Subject: [PATCH 42/43] refac --- .../layout/Sidebar/ChannelModal.svelte | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/lib/components/layout/Sidebar/ChannelModal.svelte b/src/lib/components/layout/Sidebar/ChannelModal.svelte index e0d639624e..1000caf0a3 100644 --- a/src/lib/components/layout/Sidebar/ChannelModal.svelte +++ b/src/lib/components/layout/Sidebar/ChannelModal.svelte @@ -118,19 +118,21 @@ submitHandler(); }} > -
-
{$i18n.t('Channel Type')}
+ {#if !edit} +
+
{$i18n.t('Channel Type')}
-
- +
+ +
-
+ {/if}
@@ -152,7 +154,7 @@
-
+
{#if type === 'dm'} From 022f9ff3a5e7c38115ba264d250beb3132686553 Mon Sep 17 00:00:00 2001 From: RomualdYT Date: Thu, 27 Nov 2025 15:21:27 +0100 Subject: [PATCH 43/43] Update french translation.json (#19547) --- src/lib/i18n/locales/fr-FR/translation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/i18n/locales/fr-FR/translation.json b/src/lib/i18n/locales/fr-FR/translation.json index 1d6dd67662..3f1a807de6 100644 --- a/src/lib/i18n/locales/fr-FR/translation.json +++ b/src/lib/i18n/locales/fr-FR/translation.json @@ -153,10 +153,10 @@ "Ask a question": "Posez votre question", "Assistant": "Assistant", "Async Embedding Processing": "", - "Attach File From Knowledge": "", + "Attach File From Knowledge": "Joindre un fichier depuis les connaissances", "Attach Knowledge": "Joindre une connaissance", "Attach Notes": "Joindre une note", - "Attach Webpage": "", + "Attach Webpage": "Joindre une page web", "Attention to detail": "Attention aux détails", "Attribute for Mail": "Attribut pour l'e-mail", "Attribute for Username": "Attribut pour le nom d'utilisateur",