From 69722ba973768a5f689f2e2351bf583a8db9bba8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 06:32:27 -0500 Subject: [PATCH 001/140] 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 002/140] 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 003/140] 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 004/140] 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 005/140] 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 006/140] 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 007/140] 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 008/140] 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 009/140] 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 010/140] 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 011/140] 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 012/140] 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 013/140] 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 014/140] 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 015/140] 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 016/140] 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 017/140] 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 018/140] 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 019/140] 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 020/140] 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 021/140] 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 022/140] 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 027/140] 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 028/140] 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 029/140] 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 030/140] 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 031/140] 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 032/140] 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 033/140] 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 034/140] 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 035/140] 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 036/140] 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 037/140] 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 038/140] 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 039/140] 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 040/140] 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 041/140] 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 042/140] 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 043/140] 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", From 99a7823e0131d26ebe81cbdf907c25241e35a214 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 01:17:43 -0500 Subject: [PATCH 044/140] refac: db --- .../38d63c18f30f_add_oauth_session_table.py | 11 +++--- backend/open_webui/models/messages.py | 2 +- backend/open_webui/models/models.py | 2 +- backend/open_webui/models/notes.py | 2 +- backend/open_webui/models/oauth_sessions.py | 2 +- backend/open_webui/models/tools.py | 2 +- backend/open_webui/models/users.py | 2 +- src/lib/components/chat/Settings/About.svelte | 36 ++----------------- 8 files changed, 16 insertions(+), 43 deletions(-) diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py index 8ead6db6d4..b1930e6389 100644 --- a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -23,15 +23,18 @@ def upgrade() -> None: # Create oauth_session table op.create_table( "oauth_session", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True), + sa.Column( + "user_id", + sa.Text(), + sa.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("provider", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False), sa.Column("expires_at", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), ) # Create indexes for better performance diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 1094035fd5..7901f3af66 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -40,7 +40,7 @@ class MessageReactionModel(BaseModel): class Message(Base): __tablename__ = "message" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) channel_id = Column(Text, nullable=True) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 329b87a91f..8ddcf59d39 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -53,7 +53,7 @@ class ModelMeta(BaseModel): class Model(Base): __tablename__ = "model" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) """ The model's id as used in the API. If set to an existing model, it will override the model. """ diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index f1b11f071e..af75fab598 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -23,7 +23,7 @@ from sqlalchemy.sql import exists class Note(Base): __tablename__ = "note" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) title = Column(Text) diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index b0e465dbe7..d07faad35e 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -25,7 +25,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class OAuthSession(Base): __tablename__ = "oauth_session" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text, nullable=False) provider = Column(Text, nullable=False) token = Column( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 48f84b3ac4..7f6c7fd3f5 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -24,7 +24,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Tool(Base): __tablename__ = "tool" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) name = Column(Text) content = Column(Text) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 5809a7124f..2fa634097c 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -24,7 +24,7 @@ import datetime class User(Base): __tablename__ = "user" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) name = Column(String) email = Column(String) diff --git a/src/lib/components/chat/Settings/About.svelte b/src/lib/components/chat/Settings/About.svelte index 215b53b511..7863e99eb7 100644 --- a/src/lib/components/chat/Settings/About.svelte +++ b/src/lib/components/chat/Settings/About.svelte @@ -157,40 +157,10 @@ class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} Open WebUI (Timothy Jaeryang Baek)Open WebUI Inc. All rights reserved. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. - -5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. - -6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
From 32c888c2807f4edc6b756bf1f734f8d520e8b5d3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 01:40:52 -0500 Subject: [PATCH 045/140] refac --- src/lib/components/channel/Channel.svelte | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index 3caf18bcab..7e4f1f6b7a 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -5,7 +5,14 @@ import { onDestroy, onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; - import { chatId, channelId as _channelId, showSidebar, socket, user } from '$lib/stores'; + import { + chatId, + channels, + channelId as _channelId, + showSidebar, + socket, + user + } from '$lib/stores'; import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; import Messages from './Messages.svelte'; @@ -53,6 +60,18 @@ type: 'last_read_at' } }); + + channels.set( + $channels.map((channel) => { + if (channel.id === channelId) { + return { + ...channel, + unread_count: 0 + }; + } + return channel; + }) + ); }; const initHandler = async () => { From 15dc6077795efa72f4b8043f737bbe3f349c71b1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 02:34:25 -0500 Subject: [PATCH 046/140] refac: rm print --- backend/open_webui/utils/misc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 5591fcdb3f..c7ff2a3edd 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -54,7 +54,6 @@ def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> b return True allow_list, block_list = get_allow_block_lists(filter_list) - print(string, allow_list, block_list) # If allow list is non-empty, require domain to match one of them if allow_list: From 6ee50770cd0afc7237ef17774be7433403324b82 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 02:44:36 -0500 Subject: [PATCH 047/140] refac --- .../chat/Messages/Markdown/Source.svelte | 20 ++++++++++--------- .../chat/Messages/Markdown/SourceToken.svelte | 3 ++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown/Source.svelte b/src/lib/components/chat/Messages/Markdown/Source.svelte index d48525e070..2185599898 100644 --- a/src/lib/components/chat/Messages/Markdown/Source.svelte +++ b/src/lib/components/chat/Messages/Markdown/Source.svelte @@ -1,4 +1,6 @@ {#if title !== 'N/A'} @@ -41,7 +43,7 @@ }} > - {getDisplayTitle(formattedTitle(decodeURIComponent(title)))} + {getDisplayTitle(formattedTitle(decodeString(title)))} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte index fdc422c62a..bd02408e36 100644 --- a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte @@ -1,5 +1,6 @@ - - - + + + From 4b6773885cd7527c5a56b963781dac5e95105eec Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:24:25 -0500 Subject: [PATCH 051/140] enh: dm active user indicator --- backend/open_webui/models/users.py | 6 +++++ backend/open_webui/routers/channels.py | 10 ++++++-- .../layout/Sidebar/ChannelItem.svelte | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 2fa634097c..e7beeee1bf 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -125,6 +125,12 @@ class UserIdNameResponse(BaseModel): name: str +class UserIdNameStatusResponse(BaseModel): + id: str + name: str + is_active: bool + + class UserInfoListResponse(BaseModel): users: list[UserInfoResponse] total: int diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 1bf905155e..a3228f5c80 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -14,6 +14,7 @@ from open_webui.socket.main import ( ) from open_webui.models.users import ( UserIdNameResponse, + UserIdNameStatusResponse, UserListResponse, UserModelResponse, Users, @@ -68,7 +69,7 @@ router = APIRouter() class ChannelListItemResponse(ChannelModel): user_ids: Optional[list[str]] = None # 'dm' channels only - users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + users: Optional[list[UserIdNameStatusResponse]] = None # 'dm' channels only last_message_at: Optional[int] = None # timestamp in epoch (time_ns) unread_count: int = 0 @@ -97,7 +98,12 @@ async def get_channels(user=Depends(get_verified_user)): for member in Channels.get_members_by_channel_id(channel.id) ] users = [ - UserIdNameResponse(**user.model_dump()) + UserIdNameStatusResponse( + **{ + **user.model_dump(), + "is_active": get_active_status_by_user_id(user.id), + } + ) for user in Users.get_users_by_user_ids(user_ids) ] diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 11030727eb..cf148658b8 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -5,6 +5,7 @@ import { page } from '$app/stores'; import { channels, mobile, showSidebar, user } from '$lib/stores'; + import { getUserActiveStatusById } from '$lib/apis/users'; import { updateChannelById, updateChannelMemberActiveStatusById } from '$lib/apis/channels'; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -83,8 +84,9 @@
{#if channel?.type === 'dm'} {#if channel?.users} -
- {#each channel.users.filter((u) => u.id !== $user?.id).slice(0, 2) as u, index} + {@const channelMembers = channel.users.filter((u) => u.id !== $user?.id)} +
+ {#each channelMembers.slice(0, 2) as u, index} {u.name} {/each} + + {#if channelMembers.length === 1} +
+ + {#if channelMembers[0]?.is_active} + + {/if} + + +
+ {/if}
{:else} From b99c9b277a732c530dc0fb7c4e64c1f9207e9002 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:29:50 -0500 Subject: [PATCH 052/140] refac: styling --- src/lib/components/chat/SettingsModal.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index d782f0a9cc..6e7825ba3f 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -569,7 +569,7 @@ }); - +
{$i18n.t('Settings')}
@@ -588,7 +588,7 @@
-
+
{#if selectedTab === 'general'} Date: Fri, 28 Nov 2025 06:29:41 -0500 Subject: [PATCH 053/140] refac: user table db migration --- .../b10670c03dd5_update_user_table.py | 264 ++++++++++++++++++ backend/open_webui/models/users.py | 114 +++++--- 2 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py new file mode 100644 index 0000000000..ce7d3d9870 --- /dev/null +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -0,0 +1,264 @@ +"""Update user table + +Revision ID: b10670c03dd5 +Revises: 2f1211949ecc +Create Date: 2025-11-28 04:55:31.737538 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +import open_webui.internal.db +import json +import time + +# revision identifiers, used by Alembic. +revision: str = "b10670c03dd5" +down_revision: Union[str, None] = "2f1211949ecc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _drop_sqlite_indexes_for_column(table_name, column_name, conn): + """ + SQLite requires manual removal of any indexes referencing a column + before ALTER TABLE ... DROP COLUMN can succeed. + """ + indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall() + + for idx in indexes: + index_name = idx[1] # index name + # Get indexed columns + idx_info = conn.execute( + sa.text(f"PRAGMA index_info('{index_name}')") + ).fetchall() + + indexed_cols = [row[2] for row in idx_info] # col names + if column_name in indexed_cols: + conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}")) + + +def _convert_column_to_json(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + # SQLite cannot ALTER COLUMN → must recreate column + if dialect == "sqlite": + # 1. Add temporary column + op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True)) + + # 2. Load old data + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for row in rows: + uid, raw = row + if raw is None: + parsed = None + else: + try: + parsed = json.loads(raw) + except Exception: + parsed = None # fallback safe behavior + + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'), + {"val": json.dumps(parsed) if parsed else None, "id": uid}, + ) + + # 3. Drop old TEXT column + op.drop_column(table, column) + + # 4. Rename new JSON column → original name + op.alter_column(table, f"{column}_json", new_column_name=column) + + else: + # PostgreSQL supports direct CAST + op.alter_column( + table, + column, + type_=sa.JSON(), + postgresql_using=f"{column}::json", + ) + + +def _convert_column_to_text(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + if dialect == "sqlite": + op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True)) + + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for uid, raw in rows: + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'), + {"val": json.dumps(raw) if raw else None, "id": uid}, + ) + + op.drop_column(table, column) + op.alter_column(table, f"{column}_text", new_column_name=column) + + else: + op.alter_column( + table, + column, + type_=sa.Text(), + postgresql_using=f"to_json({column})::text", + ) + + +def upgrade() -> None: + + op.add_column( + "user", + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + default=False, + server_default=sa.sql.expression.false(), + ), + ) + + op.add_column( + "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) + ) + + op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + + op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) + op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) + op.add_column( + "user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True) + ) + + op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True)) + + # Convert info (TEXT/JSONField) → JSON + _convert_column_to_json("user", "info") + # Convert settings (TEXT/JSONField) → JSON + _convert_column_to_json("user", "settings") + + op.create_table( + "api_key", + sa.Column("id", sa.Text(), primary_key=True, unique=True), + sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")), + sa.Column("key", sa.Text(), unique=True, nullable=False), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("expires_at", sa.BigInteger(), nullable=True), + sa.Column("last_used_at", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL') + ).fetchall() + + for uid, oauth_sub in users: + if oauth_sub: + # Example formats supported: + # provider@sub + # plain sub (stored as {"oidc": {"sub": sub}}) + if "@" in oauth_sub: + provider, sub = oauth_sub.split("@", 1) + else: + provider, sub = "oidc", oauth_sub + + oauth_json = json.dumps({provider: {"sub": sub}}) + conn.execute( + sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'), + {"oauth": oauth_json, "id": uid}, + ) + + users_with_keys = conn.execute( + sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL') + ).fetchall() + now = int(time.time()) + + for uid, api_key in users_with_keys: + if api_key: + conn.execute( + sa.text( + """ + INSERT INTO api_key (id, user_id, key, created_at, updated_at) + VALUES (:id, :user_id, :key, :created_at, :updated_at) + """ + ), + { + "id": f"key_{uid}", + "user_id": uid, + "key": api_key, + "created_at": now, + "updated_at": now, + }, + ) + + if conn.dialect.name == "sqlite": + _drop_sqlite_indexes_for_column("user", "api_key", conn) + _drop_sqlite_indexes_for_column("user", "oauth_sub", conn) + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("api_key") + batch_op.drop_column("oauth_sub") + + +def downgrade() -> None: + # --- 1. Restore old oauth_sub column --- + op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True)) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL') + ).fetchall() + + for uid, oauth in users: + try: + data = json.loads(oauth) + provider = list(data.keys())[0] + sub = data[provider].get("sub") + oauth_sub = f"{provider}@{sub}" + except Exception: + oauth_sub = None + + conn.execute( + sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'), + {"oauth_sub": oauth_sub, "id": uid}, + ) + + op.drop_column("user", "oauth") + + # --- 2. Restore api_key field --- + op.add_column("user", sa.Column("api_key", sa.String(), nullable=True)) + + # Restore values from api_key + keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall() + for uid, key in keys: + conn.execute( + sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'), + {"key": key, "id": uid}, + ) + + # Drop new table + op.drop_table("api_key") + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("is_active") + + batch_op.drop_column("profile_banner_image_url") + batch_op.drop_column("timezone") + + batch_op.drop_column("status_emoji") + batch_op.drop_column("status_message") + batch_op.drop_column("status_expires_at") + + # Convert info (JSON) → TEXT + _convert_column_to_text("user", "info") + # Convert settings (JSON) → TEXT + _convert_column_to_text("user", "settings") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index e7beeee1bf..b68b195cd2 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -11,7 +11,17 @@ from open_webui.utils.misc import throttle from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select +from sqlalchemy import ( + BigInteger, + JSON, + Column, + String, + Boolean, + Text, + Date, + exists, + select, +) from sqlalchemy import or_, case import datetime @@ -21,59 +31,71 @@ import datetime #################### -class User(Base): - __tablename__ = "user" - - id = Column(String, primary_key=True, unique=True) - name = Column(String) - - email = Column(String) - username = Column(String(50), nullable=True) - - role = Column(String) - profile_image_url = Column(Text) - - bio = Column(Text, nullable=True) - gender = Column(Text, nullable=True) - date_of_birth = Column(Date, nullable=True) - - info = Column(JSONField, nullable=True) - settings = Column(JSONField, nullable=True) - - api_key = Column(String, nullable=True, unique=True) - oauth_sub = Column(Text, unique=True) - - last_active_at = Column(BigInteger) - - updated_at = Column(BigInteger) - created_at = Column(BigInteger) - - class UserSettings(BaseModel): ui: Optional[dict] = {} model_config = ConfigDict(extra="allow") pass +class User(Base): + __tablename__ = "user" + + id = Column(String, primary_key=True, unique=True) + email = Column(String) + username = Column(String(50), nullable=True) + role = Column(String) + + name = Column(String) + is_active = Column(Boolean, nullable=False, default=False) + + profile_image_url = Column(Text) + profile_banner_image_url = Column(Text, nullable=True) + + bio = Column(Text, nullable=True) + gender = Column(Text, nullable=True) + date_of_birth = Column(Date, nullable=True) + timezone = Column(String, nullable=True) + + status_emoji = Column(String, nullable=True) + status_message = Column(Text, nullable=True) + status_expires_at = Column(BigInteger, nullable=True) + + info = Column(JSON, nullable=True) + settings = Column(JSON, nullable=True) + + oauth = Column(JSON, nullable=True) + + last_active_at = Column(BigInteger) + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + class UserModel(BaseModel): id: str - name: str email: str username: Optional[str] = None - role: str = "pending" + + name: str + is_active: bool = False + profile_image_url: str + profile_banner_image_url: Optional[str] = None bio: Optional[str] = None gender: Optional[str] = None date_of_birth: Optional[datetime.date] = None + timezone: Optional[str] = None + + status_emoji: Optional[str] = None + status_message: Optional[str] = None + status_expires_at: Optional[int] = None info: Optional[dict] = None settings: Optional[UserSettings] = None - api_key: Optional[str] = None - oauth_sub: Optional[str] = None + oauth: Optional[dict] = None last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -82,6 +104,32 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class ApiKey(Base): + __tablename__ = "api_key" + + id = Column(Text, primary_key=True, unique=True) + user_id = Column(Text, nullable=False) + key = Column(Text, unique=True, nullable=False) + data = Column(JSON, nullable=True) + expires_at = Column(BigInteger, nullable=True) + last_used_at = Column(BigInteger, nullable=True) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class ApiKeyModel(BaseModel): + id: str + user_id: str + key: str + data: Optional[dict] = None + expires_at: Optional[int] = None + last_used_at: Optional[int] = None + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + #################### # Forms #################### From 0a4358c3d181c48ec2400d4d80c8450108600dc9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:39:36 -0500 Subject: [PATCH 054/140] refac: oauth_sub -> oauth migration --- backend/open_webui/models/auths.py | 4 +-- backend/open_webui/models/users.py | 41 +++++++++++++++++++++++------- backend/open_webui/utils/oauth.py | 15 ++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 0d0b881a78..8b03580e6c 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -88,7 +88,7 @@ class AuthsTable: name: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: log.info("insert_new_auth") @@ -102,7 +102,7 @@ class AuthsTable: db.add(result) user = Users.insert_new_user( - id, name, email, profile_image_url, role, oauth_sub + id, name, email, profile_image_url, role, oauth=oauth ) db.commit() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index b68b195cd2..6a2f3bc9b0 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -225,7 +225,7 @@ class UsersTable: email: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: user = UserModel( @@ -238,7 +238,7 @@ class UsersTable: "last_active_at": int(time.time()), "created_at": int(time.time()), "updated_at": int(time.time()), - "oauth_sub": oauth_sub, + "oauth": oauth, } ) result = User(**user.model_dump()) @@ -274,11 +274,15 @@ class UsersTable: except Exception: return None - def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]: + def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(oauth_sub=sub).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .filter(User.oauth.contains({provider: {"sub": sub}})) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -493,16 +497,35 @@ class UsersTable: except Exception: return None - def update_user_oauth_sub_by_id( - self, id: str, oauth_sub: str + def update_user_oauth_by_id( + self, id: str, provider: str, sub: str ) -> Optional[UserModel]: + """ + Update or insert an OAuth provider/sub pair into the user's oauth JSON field. + Example resulting structure: + { + "google": { "sub": "123" }, + "github": { "sub": "abc" } + } + """ try: with get_db() as db: - db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub}) + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + # Load existing oauth JSON or create empty + oauth = user.oauth or {} + + # Update or insert provider entry + oauth[provider] = {"sub": sub} + + # Persist updated JSON + db.query(User).filter_by(id=id).update({"oauth": oauth}) db.commit() - user = db.query(User).filter_by(id=id).first() return UserModel.model_validate(user) + except Exception: return None diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index f8a924e8d0..6bd955e90c 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1329,7 +1329,10 @@ class OAuthManager: log.warning(f"OAuth callback failed, sub is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - provider_sub = f"{provider}@{sub}" + oauth_data = {} + oauth_data[provider] = { + "sub": sub, + } # Email extraction email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM @@ -1376,12 +1379,12 @@ class OAuthManager: log.warning(f"Error fetching GitHub email: {e}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) elif ENABLE_OAUTH_EMAIL_FALLBACK: - email = f"{provider_sub}.local" + email = f"{provider}@{sub}.local" else: log.warning(f"OAuth callback failed, email is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - email = email.lower() + email = email.lower() # If allowed domains are configured, check if the email domain is in the list if ( "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS @@ -1394,7 +1397,7 @@ class OAuthManager: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists - user = Users.get_user_by_oauth_sub(provider_sub) + user = Users.get_user_by_oauth_sub(provider, sub) if not user: # If the user does not exist, check if merging is enabled if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: @@ -1402,7 +1405,7 @@ class OAuthManager: user = Users.get_user_by_email(email) if user: # Update the user with the new oauth sub - Users.update_user_oauth_sub_by_id(user.id, provider_sub) + Users.update_user_oauth_by_id(user.id, provider, sub) if user: determined_role = self.get_user_role(user, user_data) @@ -1461,7 +1464,7 @@ class OAuthManager: name=name, profile_image_url=picture_url, role=self.get_user_role(None, user_data), - oauth_sub=provider_sub, + oauth=oauth_data, ) if auth_manager_config.WEBHOOK_URL: From 742832a850c5590d03df738b83fa27b4ddda3aab Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:41:41 -0500 Subject: [PATCH 055/140] refac --- backend/open_webui/models/users.py | 2 +- src/lib/components/admin/Users/UserList/EditUserModal.svelte | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 6a2f3bc9b0..7e207eeee1 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -231,8 +231,8 @@ class UsersTable: user = UserModel( **{ "id": id, - "name": name, "email": email, + "name": name, "role": role, "profile_image_url": profile_image_url, "last_active_at": int(time.time()), diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 9adbac0e4f..1803eaecbd 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -180,12 +180,12 @@
- {#if _user?.oauth_sub} + {#if _user?.oauth}
{$i18n.t('OAuth ID')}
- {_user.oauth_sub ?? ''} + {JSON.stringify(_user.oauth ?? '')}
{/if} From dcf50c47584498bad94defd38b13b84b11a40aa1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:49:10 -0500 Subject: [PATCH 056/140] refac: api_key table migration --- backend/open_webui/models/users.py | 53 ++++++++++++++++++++++------- backend/open_webui/routers/auths.py | 3 +- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 7e207eeee1..1ae33f6537 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -261,8 +261,13 @@ class UsersTable: def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(api_key=api_key).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .join(ApiKey, User.id == ApiKey.user_id) + .filter(ApiKey.key == api_key) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -579,23 +584,45 @@ class UsersTable: except Exception: return False - def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: - try: - with get_db() as db: - result = db.query(User).filter_by(id=id).update({"api_key": api_key}) - db.commit() - return True if result == 1 else False - except Exception: - return False - def get_user_api_key_by_id(self, id: str) -> Optional[str]: try: with get_db() as db: - user = db.query(User).filter_by(id=id).first() - return user.api_key + api_key = db.query(ApiKey).filter_by(user_id=id).first() + return api_key.key if api_key else None except Exception: return None + def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + + now = int(time.time()) + new_api_key = ApiKey( + id=f"key_{id}", + user_id=id, + key=api_key, + created_at=now, + updated_at=now, + ) + db.add(new_api_key) + db.commit() + + return True + + except Exception: + return False + + def delete_user_api_key_by_id(self, id: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + return True + except Exception: + return False + def get_valid_user_ids(self, user_ids: list[str]) -> list[str]: with get_db() as db: users = db.query(User).filter(User.id.in_(user_ids)).all() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 24cbd9a03f..1b79d84cfd 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1133,8 +1133,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)): # delete api key @router.delete("/api_key", response_model=bool) async def delete_api_key(user=Depends(get_current_user)): - success = Users.update_user_api_key_by_id(user.id, None) - return success + return Users.delete_user_api_key_by_id(user.id) # get api key From 8ef482a52aa7587d11c42311cbd0809cddb3172f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:59:59 -0500 Subject: [PATCH 057/140] refac: user oauth display --- .../components/admin/Users/UserList/EditUserModal.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 1803eaecbd..f73551219a 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -184,8 +184,13 @@
{$i18n.t('OAuth ID')}
-
- {JSON.stringify(_user.oauth ?? '')} +
+ {#each Object.keys(_user.oauth) as key} +
+ {key} + {_user.oauth[key]?.sub} +
+ {/each}
{/if} From c2634d45ad496c077fa6699fd8822993f2031ad7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:27:55 -0500 Subject: [PATCH 058/140] refac --- .../b10670c03dd5_update_user_table.py | 17 ++----------- backend/open_webui/models/users.py | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py index ce7d3d9870..f35a382645 100644 --- a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -113,24 +113,12 @@ def _convert_column_to_text(table: str, column: str): def upgrade() -> None: - - op.add_column( - "user", - sa.Column( - "is_active", - sa.Boolean(), - nullable=False, - default=False, - server_default=sa.sql.expression.false(), - ), - ) - op.add_column( "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) ) - op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True)) op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) op.add_column( @@ -249,11 +237,10 @@ def downgrade() -> None: op.drop_table("api_key") with op.batch_alter_table("user") as batch_op: - batch_op.drop_column("is_active") - batch_op.drop_column("profile_banner_image_url") batch_op.drop_column("timezone") + batch_op.drop_column("presence_state") batch_op.drop_column("status_emoji") batch_op.drop_column("status_message") batch_op.drop_column("status_expires_at") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 1ae33f6537..c09cc93934 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -46,7 +46,6 @@ class User(Base): role = Column(String) name = Column(String) - is_active = Column(Boolean, nullable=False, default=False) profile_image_url = Column(Text) profile_banner_image_url = Column(Text, nullable=True) @@ -56,6 +55,7 @@ class User(Base): date_of_birth = Column(Date, nullable=True) timezone = Column(String, nullable=True) + presence_state = Column(String, nullable=True) status_emoji = Column(String, nullable=True) status_message = Column(Text, nullable=True) status_expires_at = Column(BigInteger, nullable=True) @@ -78,7 +78,6 @@ class UserModel(BaseModel): role: str = "pending" name: str - is_active: bool = False profile_image_url: str profile_banner_image_url: Optional[str] = None @@ -88,6 +87,7 @@ class UserModel(BaseModel): date_of_birth: Optional[datetime.date] = None timezone: Optional[str] = None + presence_state: Optional[str] = None status_emoji: Optional[str] = None status_message: Optional[str] = None status_expires_at: Optional[int] = None @@ -176,7 +176,7 @@ class UserIdNameResponse(BaseModel): class UserIdNameStatusResponse(BaseModel): id: str name: str - is_active: bool + is_active: bool = False class UserInfoListResponse(BaseModel): @@ -636,5 +636,23 @@ class UsersTable: else: return None + def get_active_user_count(self) -> int: + with get_db() as db: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + count = ( + db.query(User).filter(User.last_active_at >= three_minutes_ago).count() + ) + return count + + def is_user_active(self, user_id: str) -> bool: + with get_db() as db: + user = db.query(User).filter_by(id=user_id).first() + if user and user.last_active_at: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + return user.last_active_at >= three_minutes_ago + return False + Users = UsersTable() From 70948f8803e417459d5203839f8077fdbfbbb213 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:39:02 -0500 Subject: [PATCH 059/140] enh/refac: deprecate USER_POOL --- backend/open_webui/main.py | 6 +- backend/open_webui/models/users.py | 2 +- backend/open_webui/routers/channels.py | 10 +--- backend/open_webui/routers/users.py | 29 +--------- backend/open_webui/socket/main.py | 56 ++++++------------- backend/open_webui/utils/auth.py | 7 +-- backend/open_webui/utils/middleware.py | 5 +- backend/open_webui/utils/telemetry/metrics.py | 3 +- .../components/layout/Sidebar/UserMenu.svelte | 4 +- src/routes/+layout.svelte | 16 ++++++ 10 files changed, 50 insertions(+), 88 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 727bfe65dd..127f22e103 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -66,7 +66,6 @@ from open_webui.socket.main import ( periodic_usage_pool_cleanup, get_event_emitter, get_models_in_use, - get_active_user_ids, ) from open_webui.routers import ( audio, @@ -2021,7 +2020,10 @@ async def get_current_usage(user=Depends(get_verified_user)): This is an experimental endpoint and subject to change. """ try: - return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()} + return { + "model_ids": get_models_in_use(), + "user_count": Users.get_active_user_count(), + } except Exception as e: log.error(f"Error getting usage statistics: {e}") raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index c09cc93934..ede5f5e761 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -489,7 +489,7 @@ class UsersTable: return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]: + def update_last_active_by_id(self, id: str) -> Optional[UserModel]: try: with get_db() as db: db.query(User).filter_by(id=id).update( diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index a3228f5c80..394c9f0009 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -10,7 +10,6 @@ from pydantic import BaseModel from open_webui.socket.main import ( sio, get_user_ids_from_room, - get_active_status_by_user_id, ) from open_webui.models.users import ( UserIdNameResponse, @@ -99,10 +98,7 @@ async def get_channels(user=Depends(get_verified_user)): ] users = [ UserIdNameStatusResponse( - **{ - **user.model_dump(), - "is_active": get_active_status_by_user_id(user.id), - } + **{**user.model_dump(), "is_active": Users.is_user_active(user.id)} ) for user in Users.get_users_by_user_ids(user_ids) ] @@ -284,7 +280,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], @@ -316,7 +312,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 9b30ba8f20..7c4b801f4d 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -26,12 +26,6 @@ from open_webui.models.users import ( UserUpdateForm, ) - -from open_webui.socket.main import ( - get_active_status_by_user_id, - get_active_user_ids, - get_user_active_status, -) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR @@ -51,23 +45,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() -############################ -# GetActiveUsers -############################ - - -@router.get("/active") -async def get_active_users( - user=Depends(get_verified_user), -): - """ - Get a list of active users. - """ - return { - "user_ids": get_active_user_ids(), - } - - ############################ # GetUsers ############################ @@ -364,7 +341,7 @@ async def update_user_info_by_session_user( class UserActiveResponse(BaseModel): name: str profile_image_url: Optional[str] = None - active: Optional[bool] = None + is_active: bool model_config = ConfigDict(extra="allow") @@ -390,7 +367,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): **{ "id": user.id, "name": user.name, - "active": get_active_status_by_user_id(user_id), + "is_active": Users.is_user_active(user_id), } ) else: @@ -457,7 +434,7 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u @router.get("/{user_id}/active", response_model=dict) async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): return { - "active": get_user_active_status(user_id), + "active": Users.is_user_active(user_id), } diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 04b67dd786..84705648d9 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -132,12 +132,6 @@ if WEBSOCKET_MANAGER == "redis": redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) - USER_POOL = RedisDict( - f"{REDIS_KEY_PREFIX}:user_pool", - redis_url=WEBSOCKET_REDIS_URL, - redis_sentinels=redis_sentinels, - redis_cluster=WEBSOCKET_REDIS_CLUSTER, - ) USAGE_POOL = RedisDict( f"{REDIS_KEY_PREFIX}:usage_pool", redis_url=WEBSOCKET_REDIS_URL, @@ -159,7 +153,6 @@ else: MODELS = {} SESSION_POOL = {} - USER_POOL = {} USAGE_POOL = {} aquire_func = release_func = renew_func = lambda: True @@ -235,16 +228,6 @@ def get_models_in_use(): return models_in_use -def get_active_user_ids(): - """Get the list of active user IDs.""" - return list(USER_POOL.keys()) - - -def get_user_active_status(user_id): - """Check if a user is currently active.""" - return user_id in USER_POOL - - def get_user_id_from_session_pool(sid): user = SESSION_POOL.get(sid) if user: @@ -270,12 +253,6 @@ def get_user_ids_from_room(room): return active_user_ids -def get_active_status_by_user_id(user_id): - if user_id in USER_POOL: - return True - return False - - @sio.on("usage") async def usage(sid, data): if sid in SESSION_POOL: @@ -303,11 +280,6 @@ async def connect(sid, environ, auth): SESSION_POOL[sid] = user.model_dump( exclude=["date_of_birth", "bio", "gender"] ) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] - await sio.enter_room(sid, f"user:{user.id}") @@ -326,11 +298,15 @@ async def user_join(sid, data): if not user: return - SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"]) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] + SESSION_POOL[sid] = user.model_dump( + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] + ) await sio.enter_room(sid, f"user:{user.id}") # Join all the channels @@ -341,6 +317,13 @@ async def user_join(sid, data): return {"id": user.id, "name": user.name} +@sio.on("heartbeat") +async def heartbeat(sid, data): + user = SESSION_POOL.get(sid) + if user: + Users.update_last_active_by_id(user["id"]) + + @sio.on("join-channels") async def join_channel(sid, data): auth = data["auth"] if "auth" in data else None @@ -669,13 +652,6 @@ async def disconnect(sid): if sid in SESSION_POOL: user = SESSION_POOL[sid] del SESSION_POOL[sid] - - user_id = user["id"] - USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid] - - if len(USER_POOL[user_id]) == 0: - del USER_POOL[user_id] - await YDOC_MANAGER.remove_user_from_all_documents(sid) else: pass diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index f3069a093f..3f05256c70 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -344,9 +344,7 @@ async def get_current_user( # Refresh the user's last active timestamp asynchronously # to prevent blocking the request if background_tasks: - background_tasks.add_task( - Users.update_user_last_active_by_id, user.id - ) + background_tasks.add_task(Users.update_last_active_by_id, user.id) return user else: raise HTTPException( @@ -397,8 +395,7 @@ def get_current_user_by_api_key(request, api_key: str): current_span.set_attribute("client.user.role", user.role) current_span.set_attribute("client.auth.type", "api_key") - Users.update_user_last_active_by_id(user.id) - + Users.update_last_active_by_id(user.id) return user diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index cc2de8e1c7..dc45daca0e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -32,7 +32,6 @@ from open_webui.models.users import Users from open_webui.socket.main import ( get_event_call, get_event_emitter, - get_active_status_by_user_id, ) from open_webui.routers.tasks import ( generate_queries, @@ -1915,7 +1914,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( @@ -3210,7 +3209,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 85bd418844..d935ddaafa 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -45,7 +45,6 @@ from open_webui.env import ( OTEL_METRICS_OTLP_SPAN_EXPORTER, OTEL_METRICS_EXPORTER_OTLP_INSECURE, ) -from open_webui.socket.main import get_active_user_ids from open_webui.models.users import Users _EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds @@ -135,7 +134,7 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: ) -> Sequence[metrics.Observation]: return [ metrics.Observation( - value=len(get_active_user_ids()), + value=Users.get_active_user_count(), ) ] diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 6ad91050e4..5da759ee88 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -222,7 +222,7 @@ {#if showActiveUsers && usage} - {#if usage?.user_ids?.length > 0} + {#if usage?.user_count}
- {usage?.user_ids?.length} + {usage?.user_count}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a153423909..ca6777bbee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -90,6 +90,8 @@ let showRefresh = false; + let heartbeatInterval = null; + const BREAKPOINT = 768; const setupSocket = async (enableWebsocket) => { @@ -126,6 +128,14 @@ } } + // Send heartbeat every 30 seconds + heartbeatInterval = setInterval(() => { + if (_socket.connected) { + console.log('Sending heartbeat'); + _socket.emit('heartbeat', {}); + } + }, 30000); + if (deploymentId !== null) { WEBUI_DEPLOYMENT_ID.set(deploymentId); } @@ -154,6 +164,12 @@ _socket.on('disconnect', (reason, details) => { console.log(`Socket ${_socket.id} disconnected due to ${reason}`); + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (details) { console.log('Additional details:', details); } From 33b59adf27f515d804f54f49733ba88504063204 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:42:45 -0500 Subject: [PATCH 060/140] refac --- src/lib/components/channel/Messages/Message/UserStatus.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte index 7bca57774c..6ec9caa052 100644 --- a/src/lib/components/channel/Messages/Message/UserStatus.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte @@ -23,7 +23,7 @@
- {#if user?.active} + {#if user?.is_active}
Date: Fri, 28 Nov 2025 08:01:42 -0500 Subject: [PATCH 061/140] refac: pin icons --- src/lib/components/icons/Pin.svelte | 21 +++++++++++++++++++++ src/lib/components/icons/PinSlash.svelte | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/icons/Pin.svelte diff --git a/src/lib/components/icons/Pin.svelte b/src/lib/components/icons/Pin.svelte new file mode 100644 index 0000000000..d62c9049db --- /dev/null +++ b/src/lib/components/icons/Pin.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/icons/PinSlash.svelte b/src/lib/components/icons/PinSlash.svelte index ab119b0292..6a90da3e87 100644 --- a/src/lib/components/icons/PinSlash.svelte +++ b/src/lib/components/icons/PinSlash.svelte @@ -4,6 +4,7 @@ Date: Fri, 28 Nov 2025 08:45:31 -0500 Subject: [PATCH 062/140] refac: admin user list active indicator --- src/lib/components/admin/Users/UserList.svelte | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index a76234643d..f0c9c8462e 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -355,14 +355,25 @@ -
+
user
{user.name}
+ + {#if user?.last_active_at && Date.now() / 1000 - user.last_active_at < 180} +
+ + + + +
+ {/if}
{user.email} From 451907cc9264f1a7913428072a142bd189aa1e57 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 09:32:37 -0500 Subject: [PATCH 063/140] refac --- src/lib/components/channel/MessageInput.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 02323a5f20..e36819fda7 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -865,7 +865,7 @@
- {#key $settings?.richTextInput} + {#key $settings?.richTextInput && $settings?.showFormattingToolbar} Date: Fri, 28 Nov 2025 09:58:44 -0500 Subject: [PATCH 064/140] feat/enh: pinned messages in channels --- backend/open_webui/models/messages.py | 21 ++- backend/open_webui/routers/channels.py | 120 ++++++++++++++ src/lib/apis/channels/index.ts | 78 +++++++++ src/lib/components/channel/Messages.svelte | 28 +++- .../channel/Messages/Message.svelte | 150 +++++++++++------- src/lib/components/channel/Navbar.svelte | 21 ++- .../channel/PinnedMessagesModal.svelte | 150 ++++++++++++++++++ 7 files changed, 510 insertions(+), 58 deletions(-) create mode 100644 src/lib/components/channel/PinnedMessagesModal.svelte diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 7901f3af66..6ed49ba597 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -111,6 +111,10 @@ class MessageReplyToResponse(MessageUserResponse): reply_to_message: Optional[MessageUserResponse] = None +class MessageWithReactionsResponse(MessageUserResponse): + reactions: list[Reactions] + + class MessageResponse(MessageReplyToResponse): latest_reply_at: Optional[int] reply_count: int @@ -306,6 +310,20 @@ class MessageTable: ) return MessageModel.model_validate(message) if message else None + def get_pinned_messages_by_channel_id( + self, channel_id: str, skip: int = 0, limit: int = 50 + ) -> list[MessageModel]: + with get_db() as db: + all_messages = ( + db.query(Message) + .filter_by(channel_id=channel_id, is_pinned=True) + .order_by(Message.pinned_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [MessageModel.model_validate(message) for message in all_messages] + def update_message_by_id( self, id: str, form_data: MessageForm ) -> Optional[MessageModel]: @@ -325,7 +343,7 @@ class MessageTable: db.refresh(message) return MessageModel.model_validate(message) if message else None - def update_message_pin_by_id( + def update_is_pinned_by_id( self, id: str, is_pinned: bool, pinned_by: Optional[str] = None ) -> Optional[MessageModel]: with get_db() as db: @@ -333,7 +351,6 @@ class MessageTable: message.is_pinned = is_pinned message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_by = pinned_by if is_pinned else None - message.updated_at = int(time.time_ns()) db.commit() db.refresh(message) return MessageModel.model_validate(message) if message else None diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 394c9f0009..f6e3ebe47a 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -31,6 +31,7 @@ from open_webui.models.messages import ( Messages, MessageModel, MessageResponse, + MessageWithReactionsResponse, MessageForm, ) @@ -463,6 +464,62 @@ async def get_channel_messages( return messages +############################ +# GetPinnedChannelMessages +############################ + +PAGE_ITEM_COUNT_PINNED = 20 + + +@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) +async def get_pinned_channel_messages( + id: str, page: int = 1, 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 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() + ) + + page = max(1, page) + skip = (page - 1) * PAGE_ITEM_COUNT_PINNED + limit = PAGE_ITEM_COUNT_PINNED + + message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit) + users = {} + + messages = [] + for message in message_list: + if message.user_id not in users: + user = Users.get_user_by_id(message.user_id) + users[message.user_id] = user + + messages.append( + MessageWithReactionsResponse( + **{ + **message.model_dump(), + "reactions": Messages.get_reactions_by_message_id(message.id), + "user": UserNameResponse(**users[message.user_id].model_dump()), + } + ) + ) + + return messages + + ############################ # PostNewMessage ############################ @@ -834,6 +891,69 @@ async def get_channel_message( ) +############################ +# PinChannelMessage +############################ + + +class PinMessageForm(BaseModel): + is_pinned: bool + + +@router.post( + "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] +) +async def pin_channel_message( + id: str, message_id: str, form_data: PinMessageForm, 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 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: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if message.channel_id != id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id) + message = Messages.get_message_by_id(message_id) + return MessageUserResponse( + **{ + **message.model_dump(), + "user": UserNameResponse( + **Users.get_user_by_id(message.user_id).model_dump() + ), + } + ) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # GetChannelThreadMessages ############################ diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 5b510491fe..7a954a7507 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -299,6 +299,44 @@ export const getChannelMessages = async ( return res; }; +export const getChannelPinnedMessages = async ( + token: string = '', + channel_id: string, + page: number = 1 +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/pinned?page=${page}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getChannelThreadMessages = async ( token: string = '', channel_id: string, @@ -379,6 +417,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag return res; }; +export const pinMessage = async ( + token: string = '', + channel_id: string, + message_id: string, + is_pinned: boolean +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/pin`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ is_pinned }) + } + ) + .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 updateMessage = async ( token: string = '', channel_id: string, diff --git a/src/lib/components/channel/Messages.svelte b/src/lib/components/channel/Messages.svelte index 9127fd8c38..b8e6dbb9ea 100644 --- a/src/lib/components/channel/Messages.svelte +++ b/src/lib/components/channel/Messages.svelte @@ -16,7 +16,13 @@ import Message from './Messages/Message.svelte'; import Loader from '../common/Loader.svelte'; import Spinner from '../common/Spinner.svelte'; - import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels'; + import { + addReaction, + deleteMessage, + pinMessage, + removeReaction, + updateMessage + } from '$lib/apis/channels'; import { WEBUI_API_BASE_URL } from '$lib/constants'; const i18n = getContext('i18n'); @@ -155,6 +161,26 @@ onReply={(message) => { onReply(message); }} + onPin={async (message) => { + messages = messages.map((m) => { + if (m.id === message.id) { + m.is_pinned = !m.is_pinned; + m.pinned_by = !m.is_pinned ? null : $user?.id; + m.pinned_at = !m.is_pinned ? null : Date.now() * 1000000; + } + return m; + }); + + const updatedMessage = await pinMessage( + localStorage.token, + message.channel_id, + message.id, + message.is_pinned + ).catch((error) => { + toast.error(`${error}`); + return null; + }); + }} onThread={(id) => { onThread(id); }} diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 5379a7be00..5ed17336c9 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -36,6 +36,10 @@ import Emoji from '$lib/components/common/Emoji.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte'; + import PinSlash from '$lib/components/icons/PinSlash.svelte'; + import Pin from '$lib/components/icons/Pin.svelte'; + + export let className = ''; export let message; export let showUserProfile = true; @@ -47,6 +51,7 @@ export let onDelete: Function = () => {}; export let onEdit: Function = () => {}; export let onReply: Function = () => {}; + export let onPin: Function = () => {}; export let onThread: Function = () => {}; export let onReaction: Function = () => {}; @@ -69,13 +74,17 @@ {#if message}
{#if !edit && !disabled} @@ -85,37 +94,56 @@
- (showButtons = false)} - onSubmit={(name) => { - showButtons = false; - onReaction(name); - }} - > - - - - - - - + + + {/if} + + {#if onReply} + + + + {/if} + + + - {#if !thread} + {#if !thread && onThread} - + {#if onEdit} + + + + {/if} - - - + {#if onDelete} + + + + {/if} {/if}
{/if} + {#if message?.is_pinned} +
+
+ + {$i18n.t('Pinned')} +
+
+ {/if} + {#if message?.reply_to_message?.user}
{/if} +
-
+
{#if showUserProfile} {#if message?.meta?.model_id} -
+
{#if showUserProfile}
diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index 13e4b5c415..b8d6c81807 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -18,16 +18,20 @@ import UserAlt from '../icons/UserAlt.svelte'; import ChannelInfoModal from './ChannelInfoModal.svelte'; import Users from '../icons/Users.svelte'; + import Pin from '../icons/Pin.svelte'; + import PinnedMessagesModal from './PinnedMessagesModal.svelte'; const i18n = getContext('i18n'); + let showChannelPinnedMessagesModal = false; let showChannelInfoModal = false; export let channel; + -
- -
- -
- {#if accessGroups.length > 0} + {#if accessGroups.length > 0} +
{#each accessGroups as group} -
-
+
+
- -
- -
- {group.name} + {group.name} {group?.member_count}
@@ -260,13 +210,46 @@
{/each} - {:else} -
-
- {$i18n.t('No groups with access, add a group to grant access')} +
+ {/if} + + + +
+
+
+
+
- {/if} +
From 9d39b9b42c653ee2acf2674b2df343ecbceb4954 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 30 Nov 2025 03:47:34 -0500 Subject: [PATCH 083/140] refac: styling --- src/lib/components/AddToolServerModal.svelte | 6 +- .../Evaluations/ArenaModelModal.svelte | 6 +- .../Knowledge/CreateKnowledgeBase.svelte | 14 +- .../workspace/Models/ActionsSelector.svelte | 2 +- .../workspace/Models/Capabilities.svelte | 2 +- .../workspace/Models/DefaultFeatures.svelte | 2 +- .../workspace/Models/FiltersSelector.svelte | 2 +- .../workspace/Models/Knowledge.svelte | 12 +- .../workspace/Models/ModelEditor.svelte | 290 ++++++++-------- .../workspace/Models/PromptSuggestions.svelte | 311 ++++++++---------- .../workspace/Models/ToolsSelector.svelte | 14 +- .../workspace/common/AccessControl.svelte | 8 +- .../workspace/common/Visibility.svelte | 79 +++++ 13 files changed, 409 insertions(+), 339 deletions(-) create mode 100644 src/lib/components/workspace/common/Visibility.svelte diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 79fe4c97fc..c9b91e2276 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -818,10 +818,8 @@
-
-
- -
+
+
{/if}
diff --git a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte index e3d702e6aa..2714d4681c 100644 --- a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte +++ b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte @@ -292,10 +292,8 @@
-
-
- -
+
+

diff --git a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte index 582c2f68b4..2e729f4968 100644 --- a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte @@ -112,14 +112,12 @@
-
- -
+
diff --git a/src/lib/components/workspace/Models/ActionsSelector.svelte b/src/lib/components/workspace/Models/ActionsSelector.svelte index f936988b30..444b5476fe 100644 --- a/src/lib/components/workspace/Models/ActionsSelector.svelte +++ b/src/lib/components/workspace/Models/ActionsSelector.svelte @@ -25,7 +25,7 @@ {#if actions.length > 0}
-
{$i18n.t('Actions')}
+
{$i18n.t('Actions')}
diff --git a/src/lib/components/workspace/Models/Capabilities.svelte b/src/lib/components/workspace/Models/Capabilities.svelte index 0628a377df..f1e9741dfe 100644 --- a/src/lib/components/workspace/Models/Capabilities.svelte +++ b/src/lib/components/workspace/Models/Capabilities.svelte @@ -57,7 +57,7 @@
-
{$i18n.t('Capabilities')}
+
{$i18n.t('Capabilities')}
{#each Object.keys(capabilityLabels) as capability} diff --git a/src/lib/components/workspace/Models/DefaultFeatures.svelte b/src/lib/components/workspace/Models/DefaultFeatures.svelte index 01826fc130..7da1d5b99c 100644 --- a/src/lib/components/workspace/Models/DefaultFeatures.svelte +++ b/src/lib/components/workspace/Models/DefaultFeatures.svelte @@ -27,7 +27,7 @@
-
{$i18n.t('Default Features')}
+
{$i18n.t('Default Features')}
{#each availableFeatures as feature} diff --git a/src/lib/components/workspace/Models/FiltersSelector.svelte b/src/lib/components/workspace/Models/FiltersSelector.svelte index 02216ca248..c5207e61b7 100644 --- a/src/lib/components/workspace/Models/FiltersSelector.svelte +++ b/src/lib/components/workspace/Models/FiltersSelector.svelte @@ -25,7 +25,7 @@ {#if filters.length > 0}
-
{$i18n.t('Filters')}
+
{$i18n.t('Filters')}
diff --git a/src/lib/components/workspace/Models/Knowledge.svelte b/src/lib/components/workspace/Models/Knowledge.svelte index 9d95d744dd..5c92859000 100644 --- a/src/lib/components/workspace/Models/Knowledge.svelte +++ b/src/lib/components/workspace/Models/Knowledge.svelte @@ -157,18 +157,14 @@
-
+
{$i18n.t('Knowledge')}
- -
- {$i18n.t('To attach knowledge base here, add them to the "Knowledge" workspace first.')} -
-
+
{#if selectedItems?.length > 0}
{#each selectedItems as file, fileIdx} @@ -228,4 +224,8 @@ {/if}
+ +
+ {$i18n.t('To attach knowledge base here, add them to the "Knowledge" workspace first.')} +
diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 7bdb5a0261..20d8865534 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -23,6 +23,8 @@ import DefaultFiltersSelector from './DefaultFiltersSelector.svelte'; import DefaultFeatures from './DefaultFeatures.svelte'; import PromptSuggestions from './PromptSuggestions.svelte'; + import AccessControlModal from '../common/AccessControlModal.svelte'; + import LockClosed from '$lib/components/icons/LockClosed.svelte'; const i18n = getContext('i18n'); @@ -42,6 +44,7 @@ let showAdvanced = false; let showPreview = false; + let showAccessControlModal = false; let loaded = false; @@ -317,6 +320,14 @@ {#if loaded} + + {#if onBack}
-
-
-
- +
+
+
+
+
+ +
+
+ +
+
+ +
+
-
- -
-
- -
-
-
- - {#if preset} -
-
{$i18n.t('Base Model (From)')}
- + + +
+ {$i18n.t('Access')} +
+
- {/if} -
-
-
{$i18n.t('Description')}
+ {#if preset} +
+
+ {$i18n.t('Base Model (From)')} +
- -
- - {#if enableDescription} -