From 7c0bf59c7c42d882edd9ed001949c65def889945 Mon Sep 17 00:00:00 2001 From: Aleix Dorca Date: Fri, 5 Sep 2025 17:17:20 +0200 Subject: [PATCH 01/52] Update catalan translation.json --- src/lib/i18n/locales/ca-ES/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/i18n/locales/ca-ES/translation.json b/src/lib/i18n/locales/ca-ES/translation.json index d83fd1c8c4..0915b05f0a 100644 --- a/src/lib/i18n/locales/ca-ES/translation.json +++ b/src/lib/i18n/locales/ca-ES/translation.json @@ -237,7 +237,7 @@ "Clear memory": "Esborrar la memòria", "Clear Memory": "Esborrar la memòria", "click here": "prem aquí", - "Click here for filter guides.": "Clica aquí per filtrar les guies.", + "Click here for filter guides.": "Clica aquí per l'ajuda dels filtres.", "Click here for help.": "Clica aquí per obtenir ajuda.", "Click here to": "Clic aquí per", "Click here to download user import template file.": "Fes clic aquí per descarregar l'arxiu de plantilla d'importació d'usuaris", From 4ca43004edcb47332aac3dad3817df967ee5817a Mon Sep 17 00:00:00 2001 From: Jesper Kristensen Date: Fri, 5 Sep 2025 12:50:27 +0200 Subject: [PATCH 02/52] feat: Added support for redis as session storage --- backend/open_webui/main.py | 48 ++++++++++++++++++++++++++++++++------ backend/requirements.txt | 1 + 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index a5d55f75ab..1153692fb3 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -50,6 +50,11 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response, StreamingResponse from starlette.datastructures import Headers +from starsessions import ( + SessionMiddleware as StarSessionsMiddleware, + SessionAutoloadMiddleware, +) +from starsessions.stores.redis import RedisStore from open_webui.utils import logger from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware @@ -1897,13 +1902,42 @@ async def get_current_usage(user=Depends(get_verified_user)): # SessionMiddleware is used by authlib for oauth if len(OAUTH_PROVIDERS) > 0: - app.add_middleware( - SessionMiddleware, - secret_key=WEBUI_SECRET_KEY, - session_cookie="oui-session", - same_site=WEBUI_SESSION_COOKIE_SAME_SITE, - https_only=WEBUI_SESSION_COOKIE_SECURE, - ) + try: + # Try to create Redis store for sessions + if REDIS_URL: + redis_session_store = RedisStore( + url=REDIS_URL, + prefix=( + f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:" + ), + ) + + # Add SessionAutoloadMiddleware first to handle session loading + app.add_middleware(SessionAutoloadMiddleware) + + app.add_middleware( + StarSessionsMiddleware, + store=redis_session_store, + cookie_name="oui-session", + cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE, + cookie_https_only=WEBUI_SESSION_COOKIE_SECURE, + ) + log.info("Using StarSessions with Redis for session management") + else: + raise ValueError("Redis URL not configured") + + except Exception as e: + log.warning( + f"Failed to initialize Redis sessions, falling back to cookie based sessions: {e}" + ) + # Fallback to existing SessionMiddleware + app.add_middleware( + SessionMiddleware, + secret_key=WEBUI_SECRET_KEY, + session_cookie="oui-session", + same_site=WEBUI_SESSION_COOKIE_SAME_SITE, + https_only=WEBUI_SESSION_COOKIE_SECURE, + ) @app.get("/oauth/{provider}/login") diff --git a/backend/requirements.txt b/backend/requirements.txt index 5871015075..f712423631 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,7 @@ aiocache aiofiles starlette-compress==1.6.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 +starsessions[redis]==2.2.1 sqlalchemy==2.0.38 alembic==1.14.0 From 9c76fb267a510579b9deb44818c1edb14c6c19ec Mon Sep 17 00:00:00 2001 From: Aleix Dorca Date: Wed, 24 Sep 2025 11:21:27 +0200 Subject: [PATCH 03/52] Update catalan translation.json --- src/lib/i18n/locales/ca-ES/translation.json | 110 ++++++++++---------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/lib/i18n/locales/ca-ES/translation.json b/src/lib/i18n/locales/ca-ES/translation.json index 3929918ef9..49a7b1f043 100644 --- a/src/lib/i18n/locales/ca-ES/translation.json +++ b/src/lib/i18n/locales/ca-ES/translation.json @@ -14,14 +14,14 @@ "{{COUNT}} extracted lines": "{{COUNT}} línies extretes", "{{COUNT}} hidden lines": "{{COUNT}} línies ocultes", "{{COUNT}} Replies": "{{COUNT}} respostes", - "{{COUNT}} Sources": "", + "{{COUNT}} Sources": "{{COUNT}} fonts", "{{COUNT}} words": "{{COUNT}} paraules", - "{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "", + "{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} a les {{LOCALIZED_TIME}}", "{{model}} download has been canceled": "La descàrrega del model {{model}} s'ha cancel·lat", "{{user}}'s Chats": "Els xats de {{user}}", "{{webUIName}} Backend Required": "El Backend de {{webUIName}} és necessari", "*Prompt node ID(s) are required for image generation": "*Els identificadors de nodes d'indicacions són necessaris per a la generació d'imatges", - "1 Source": "", + "1 Source": "1 font", "A new version (v{{LATEST_VERSION}}) is now available.": "Hi ha una nova versió disponible (v{{LATEST_VERSION}}).", "A task model is used when performing tasks such as generating titles for chats and web search queries": "Un model de tasca s'utilitza quan es realitzen tasques com ara generar títols per a xats i consultes de cerca per a la web", "a user": "un usuari", @@ -32,7 +32,7 @@ "Accessible to all users": "Accessible a tots els usuaris", "Account": "Compte", "Account Activation Pending": "Activació del compte pendent", - "accurate": "", + "accurate": "precís", "Accurate information": "Informació precisa", "Action": "Acció", "Action not found": "Acció no trobada", @@ -147,8 +147,8 @@ "Ask a question": "Fer una pregunta", "Assistant": "Assistent", "Attach file from knowledge": "Associar arxiu del coneixement", - "Attach Knowledge": "", - "Attach Notes": "", + "Attach Knowledge": "Afegir coneixement", + "Attach Notes": "Afegir notes", "Attention to detail": "Atenció al detall", "Attribute for Mail": "Atribut per al Correu", "Attribute for Username": "Atribut per al Nom d'usuari", @@ -212,7 +212,7 @@ "Capture Audio": "Capturar àudio", "Certificate Path": "Camí del certificat", "Change Password": "Canviar la contrasenya", - "Channel": "", + "Channel": "Canal", "Channel deleted successfully": "Canal suprimit correctament", "Channel Name": "Nom del canal", "Channel updated successfully": "Canal actualitzat correctament", @@ -358,7 +358,7 @@ "Custom Parameter Value": "Valor del paràmetre personalitzat", "Danger Zone": "Zona de perill", "Dark": "Fosc", - "Data Controls": "", + "Data Controls": "Controls de dades", "Database": "Base de dades", "Datalab Marker API": "API de Datalab Marker", "Datalab Marker API Key required.": "API de Datalab Marker requereix clau.", @@ -370,8 +370,8 @@ "Default (SentenceTransformers)": "Per defecte (SentenceTransformers)", "Default action buttons will be used.": "S'utilitzaran els botons d'acció per defecte", "Default description enabled": "Descripcions per defecte habilitades", - "Default Features": "", - "Default Filters": "", + "Default Features": "Característiques per defecte", + "Default Filters": "Filres 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", @@ -433,11 +433,11 @@ "Display Multi-model Responses in Tabs": "Mostrar respostes multi-model a les pestanyes", "Display the username instead of You in the Chat": "Mostrar el nom d'usuari en lloc de 'Tu' al xat", "Displays citations in the response": "Mostra les referències a la resposta", - "Displays status updates (e.g., web search progress) in the response": "", + "Displays status updates (e.g., web search progress) in the response": "Mostra actualitzacions d'estat (per exemple, progrés de la cerca web) a la resposta", "Dive into knowledge": "Aprofundir en el coneixement", - "dlparse_v1": "", - "dlparse_v2": "", - "dlparse_v4": "", + "dlparse_v1": "dlparse_v1", + "dlparse_v2": "dlparse_v2", + "dlparse_v4": "dlparse_v4", "Do not install functions from sources you do not fully trust.": "No instal·lis funcions de fonts en què no confiïs plenament.", "Do not install tools from sources you do not fully trust.": "No instal·lis eines de fonts en què no confiïs plenament.", "Docling": "Docling", @@ -487,7 +487,7 @@ "Edit Memory": "Editar la memòria", "Edit User": "Editar l'usuari", "Edit User Group": "Editar el grup d'usuaris", - "edited": "", + "edited": "editat", "Edited": "Editat", "Editing": "Editant", "Eject": "Expulsar", @@ -629,8 +629,8 @@ "Enter Your Password": "Introdueix la teva contrasenya", "Enter Your Role": "Introdueix el teu rol", "Enter Your Username": "Introdueix el teu nom d'usuari", - "Enter your webhook URL": "Entra la URL del webhook", - "Entra ID": "", + "Enter your webhook URL": "Introdueix la URL del webhook", + "Entra ID": "Introdueix l'ID", "Error": "Error", "ERROR": "ERROR", "Error accessing directory": "Error en accedir al directori", @@ -674,7 +674,7 @@ "External": "Extern", "External Document Loader URL required.": "Fa falta la URL per a Document Loader", "External Task Model": "Model de tasques extern", - "External Tools": "", + "External Tools": "Eines externes", "External Web Loader API Key": "Clau API d'External Web Loader", "External Web Loader URL": "URL d'External Web Loader", "External Web Search API Key": "Clau API d'External Web Search", @@ -698,7 +698,7 @@ "Failed to save models configuration": "No s'ha pogut desar la configuració dels models", "Failed to update settings": "No s'han pogut actualitzar les preferències", "Failed to upload file.": "No s'ha pogut pujar l'arxiu.", - "fast": "", + "fast": "ràpid", "Features": "Característiques", "Features Permissions": "Permisos de les característiques", "February": "Febrer", @@ -726,13 +726,13 @@ "Firecrawl API Key": "Clau API de Firecrawl", "Floating Quick Actions": "Accions ràpides flotants", "Focus chat input": "Estableix el focus a l'entrada del xat", - "Folder Background Image": "", + "Folder Background Image": "Imatge del fons de la carpeta", "Folder deleted successfully": "Carpeta eliminada correctament", "Folder Name": "Nom de la carpeta", "Folder name cannot be empty.": "El nom de la carpeta no pot ser buit.", "Folder name updated successfully": "Nom de la carpeta actualitzat correctament", "Folder updated successfully": "Carpeta actualitazda correctament", - "Folders": "", + "Folders": "Carpetes", "Follow up": "Seguir", "Follow Up Generation": "Generació de seguiment", "Follow Up Generation Prompt": "Indicació per a la generació de seguiment", @@ -746,7 +746,7 @@ "Format the lines in the output. Defaults to False. If set to True, the lines will be formatted to detect inline math and styles.": "Formata les línies a la sortida. Per defecte, és Fals. Si es defineix com a Cert, les línies es formataran per detectar matemàtiques i estils en línia.", "Format your variables using brackets like this:": "Formata les teves variables utilitzant claudàtors així:", "Formatting may be inconsistent from source.": "La formatació pot ser inconsistent amb l'origen", - "Forwards system user OAuth access token to authenticate": "", + "Forwards system user OAuth access token to authenticate": "Reenvia el testimoni d'accés OAuth de l'usuari del sistema per autenticar-se.", "Forwards system user session credentials to authenticate": "Envia les credencials de l'usuari del sistema per autenticar", "Full Context Mode": "Mode de context complert", "Function": "Funció", @@ -842,7 +842,7 @@ "Import Prompts": "Importar indicacions", "Import Tools": "Importar eines", "Important Update": "Actualització important", - "In order to force OCR, performing OCR must be enabled.": "", + "In order to force OCR, performing OCR must be enabled.": "Per forçar l'OCR, cal activar l'OCR.", "Include": "Incloure", "Include `--api-auth` flag when running stable-diffusion-webui": "Inclou `--api-auth` quan executis stable-diffusion-webui", "Include `--api` flag when running stable-diffusion-webui": "Inclou `--api` quan executis stable-diffusion-webui", @@ -858,11 +858,11 @@ "Insert": "Inserir", "Insert Follow-Up Prompt to Input": "Inserir un missatge de seguiment per a l'entrada", "Insert Prompt as Rich Text": "Inserir la indicació com a Text Ric", - "Insert Suggestion Prompt to Input": "", + "Insert Suggestion Prompt to Input": "Insereix un suggeriment per introduir", "Install from Github URL": "Instal·lar des de l'URL de Github", "Instant Auto-Send After Voice Transcription": "Enviament automàtic després de la transcripció de veu", "Integration": "Integració", - "Integrations": "", + "Integrations": "Integracions", "Interface": "Interfície", "Invalid file content": "Continguts del fitxer no vàlids", "Invalid file format.": "Format d'arxiu no vàlid.", @@ -921,7 +921,7 @@ "Leave empty to include all models or select specific models": "Deixa-ho en blanc per incloure tots els models o selecciona models específics", "Leave empty to use the default prompt, or enter a custom prompt": "Deixa-ho en blanc per utilitzar la indicació predeterminada o introdueix una indicació personalitzada", "Leave model field empty to use the default model.": "Deixa el camp de model buit per utilitzar el model per defecte.", - "Legacy": "", + "Legacy": "Llegat", "lexical": "lèxic", "License": "Llicència", "Lift List": "Aixecar la llista", @@ -1027,7 +1027,7 @@ "New Tool": "Nova eina", "new-channel": "nou-canal", "Next message": "Missatge següent", - "No authentication": "", + "No authentication": "Sense autenticació", "No chats found": "No s'han trobat xats", "No chats found for this user.": "No s'han trobat xats per a aquest usuari.", "No chats found.": "No s'ha trobat xats.", @@ -1048,12 +1048,12 @@ "No models found": "No s'han trobat models", "No models selected": "No s'ha seleccionat cap model", "No Notes": "No hi ha notes", - "No notes found": "", + "No notes found": "No s'han trobat notes", "No results": "No s'han trobat resultats", "No results found": "No s'han trobat resultats", "No search query generated": "No s'ha generat cap consulta", "No source available": "Sense font disponible", - "No sources found": "", + "No sources found": "No s'han trobat fonts", "No suggestion prompts": "Cap prompt suggerit", "No users were found.": "No s'han trobat usuaris", "No valves": "No hi ha valves", @@ -1062,7 +1062,7 @@ "None": "Cap", "Not factually correct": "No és clarament correcte", "Not helpful": "No ajuda", - "Note": "", + "Note": "Nota", "Note deleted successfully": "La nota s'ha eliminat correctament", "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", @@ -1070,7 +1070,7 @@ "Notification Webhook": "Webhook de la notificació", "Notifications": "Notificacions", "November": "Novembre", - "OAuth": "", + "OAuth": "OAuth", "OAuth ID": "ID OAuth", "October": "Octubre", "Off": "Desactivat", @@ -1095,10 +1095,10 @@ "Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "Ui! Estàs utilitzant un mètode no suportat (només frontend). Si us plau, serveix la WebUI des del backend.", "Open file": "Obrir arxiu", "Open in full screen": "Obrir en pantalla complerta", - "Open link": "", + "Open link": "Obrir l'enllaç", "Open modal to configure connection": "Obre el modal per configurar la connexió", "Open Modal To Manage Floating Quick Actions": "Obre el model per configurar les Accions ràpides flotants", - "Open Modal To Manage Image Compression": "", + "Open Modal To Manage Image Compression": "Obrir un modal per gestionar la compressió d'imatges", "Open new chat": "Obre un xat nou", "Open Sidebar": "Obre la barra lateral", "Open User Profile Menu": "Obre el menú de perfil d'usuari", @@ -1129,14 +1129,14 @@ "Password": "Contrasenya", "Passwords do not match.": "Les contrasenyes no coincideixen", "Paste Large Text as File": "Enganxa un text llarg com a fitxer", - "PDF Backend": "", + "PDF Backend": "Backend de PDF", "PDF document (.pdf)": "Document PDF (.pdf)", "PDF Extract Images (OCR)": "Extreu imatges del PDF (OCR)", "pending": "pendent", "Pending": "Pendent", "Pending User Overlay Content": "Contingut de la finestra d'usuari pendent", "Pending User Overlay Title": "Títol de la finestra d'usuari pendent", - "Perform OCR": "", + "Perform OCR": "Fer OCR", "Permission denied when accessing media devices": "Permís denegat en accedir a dispositius multimèdia", "Permission denied when accessing microphone": "Permís denegat en accedir al micròfon", "Permission denied when accessing microphone: {{error}}": "Permís denegat en accedir al micròfon: {{error}}", @@ -1152,7 +1152,7 @@ "Pinned": "Fixat", "Pioneer insights": "Perspectives pioneres", "Pipe": "Canonada", - "Pipeline": "", + "Pipeline": "Canonada", "Pipeline deleted successfully": "Pipeline eliminada correctament", "Pipeline downloaded successfully": "Pipeline descarregada correctament", "Pipelines": "Pipelines", @@ -1197,13 +1197,13 @@ "Prompts": "Indicacions", "Prompts Access": "Accés a les indicacions", "Prompts Public Sharing": "Compartició pública de indicacions", - "Provider Type": "", + "Provider Type": "Tipus de proveïdor", "Public": "Públic", "Pull \"{{searchValue}}\" from Ollama.com": "Obtenir \"{{searchValue}}\" de Ollama.com", "Pull a model from Ollama.com": "Obtenir un model d'Ollama.com", - "pypdfium2": "", + "pypdfium2": "pypdfium2", "Query Generation Prompt": "Indicació per a generació de consulta", - "Querying": "", + "Querying": "Consultes", "Quick Actions": "Accions ràpides", "RAG Template": "Plantilla RAG", "Rating": "Valoració", @@ -1218,7 +1218,7 @@ "Redirecting you to Open WebUI Community": "Redirigint-te a la comunitat OpenWebUI", "Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "Redueix la probabilitat de generar ximpleries. Un valor més alt (p. ex. 100) donarà respostes més diverses, mentre que un valor més baix (p. ex. 10) serà més conservador.", "Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Fes referència a tu mateix com a \"Usuari\" (p. ex., \"L'usuari està aprenent espanyol\")", - "Reference Chats": "", + "Reference Chats": "Xats de referència", "Refused when it shouldn't have": "Refusat quan no hauria d'haver estat", "Regenerate": "Regenerar", "Regenerate Menu": "Regenerar el menú", @@ -1239,7 +1239,7 @@ "Rename": "Canviar el nom", "Reorder Models": "Reordenar els models", "Reply in Thread": "Respondre al fil", - "required": "", + "required": "necessari", "Reranking Engine": "Motor de valoració", "Reranking Model": "Model de reavaluació", "Reset": "Restableix", @@ -1256,11 +1256,11 @@ "RESULT": "Resultat", "Retrieval": "Retrieval", "Retrieval Query Generation": "Generació de consultes Retrieval", - "Retrieved {{count}} sources": "", - "Retrieved {{count}} sources_one": "", - "Retrieved {{count}} sources_many": "", - "Retrieved {{count}} sources_other": "", - "Retrieved 1 source": "", + "Retrieved {{count}} sources": "S'han obtingut {{count}} fonts", + "Retrieved {{count}} sources_one": "S'han obtingut {{count}} sources_one", + "Retrieved {{count}} sources_many": "S'han obtingut {{count}} sources_many", + "Retrieved {{count}} sources_other": "S'han obtingut {{count}} sources_other", + "Retrieved 1 source": "S'ha obtingut una font", "Rich Text Input for Chat": "Entrada de text ric per al xat", "RK": "RK", "Role": "Rol", @@ -1304,7 +1304,7 @@ "SearchApi API Key": "Clau API de SearchApi", "SearchApi Engine": "Motor de SearchApi", "Searched {{count}} sites": "S'han cercat {{count}} pàgines", - "Searching": "", + "Searching": "Cercant", "Searching \"{{searchQuery}}\"": "Cercant \"{{searchQuery}}\"", "Searching Knowledge for \"{{searchQuery}}\"": "Cercant \"{{searchQuery}}\" al coneixement", "Searching the web": "Cercant la web...", @@ -1416,10 +1416,10 @@ "Speech recognition error: {{error}}": "Error de reconeixement de veu: {{error}}", "Speech-to-Text": "Àudio-a-Text", "Speech-to-Text Engine": "Motor de veu a text", - "standard": "", + "standard": "estàndard", "Start of the channel": "Inici del canal", "Start Tag": "Etiqueta d'inici", - "Status Updates": "", + "Status Updates": "Estat de les actualitzacions", "STDOUT/STDERR": "STDOUT/STDERR", "Stop": "Atura", "Stop Generating": "Atura la generació", @@ -1445,7 +1445,7 @@ "System": "Sistema", "System Instructions": "Instruccions de sistema", "System Prompt": "Indicació del Sistema", - "Table Mode": "", + "Table Mode": "Mode de taula", "Tags": "Etiquetes", "Tags Generation": "Generació d'etiquetes", "Tags Generation Prompt": "Indicació per a la generació d'etiquetes", @@ -1530,7 +1530,7 @@ "To select toolkits here, add them to the \"Tools\" workspace first.": "Per seleccionar kits d'eines aquí, afegeix-los primer a l'espai de treball \"Eines\".", "Toast notifications for new updates": "Notificacions Toast de noves actualitzacions", "Today": "Avui", - "Today at {{LOCALIZED_TIME}}": "", + "Today at {{LOCALIZED_TIME}}": "Avui a les {{LOCALIZED_TIME}}", "Toggle search": "Alternar cerca", "Toggle settings": "Alterna preferències", "Toggle sidebar": "Alterna la barra lateral", @@ -1568,7 +1568,7 @@ "Unarchive All Archived Chats": "Desarxivar tots els xats arxivats", "Unarchive Chat": "Desarxivar xat", "Underline": "Subratllat", - "Unknown": "", + "Unknown": "Desconegut", "Unloads {{FROM_NOW}}": "Es descarrega {{FROM_NOW}}", "Unlock mysteries": "Desbloqueja els misteris", "Unpin": "Alliberar", @@ -1610,7 +1610,7 @@ "User Webhooks": "Webhooks d'usuari", "Username": "Nom d'usuari", "Users": "Usuaris", - "Uses DefaultAzureCredential to authenticate": "", + "Uses DefaultAzureCredential to authenticate": "Utilitza DefaultAzureCredential per a l'autenticació", "Using Entire Document": "Utilitzant tot el document", "Using Focused Retrieval": "Utilitzant RAG", "Using the default arena model with all models. Click the plus button to add custom models.": "S'utilitza el model d'Arena predeterminat amb tots els models. Clica el botó més per afegir models personalitzats.", @@ -1628,7 +1628,7 @@ "View Result from **{{NAME}}**": "Veure el resultat de **{{NAME}}**", "Visibility": "Visibilitat", "Vision": "Visió", - "vlm": "", + "vlm": "vlm", "Voice": "Veu", "Voice Input": "Entrada de veu", "Voice mode": "Mode de veu", @@ -1673,7 +1673,7 @@ "Yacy Password": "Contrasenya de Yacy", "Yacy Username": "Nom d'usuari de Yacy", "Yesterday": "Ahir", - "Yesterday at {{LOCALIZED_TIME}}": "", + "Yesterday at {{LOCALIZED_TIME}}": "Ahir a les {{LOCALIZED_TIME}}", "You": "Tu", "You are currently using a trial license. Please contact support to upgrade your license.": "Actualment esteu utilitzant una llicència de prova. Poseu-vos en contacte amb el servei d'assistència per actualitzar la vostra llicència.", "You can only chat with a maximum of {{maxCount}} file(s) at a time.": "Només pots xatejar amb un màxim de {{maxCount}} fitxers alhora.", From 5eaee44daa72196432d00b52a5016ecacc95a1c4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 24 Sep 2025 06:49:39 -0500 Subject: [PATCH 04/52] refac --- .../components/common/RichTextInput.svelte | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 0c46d0f296..b9656954bd 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -149,10 +149,15 @@ export let onChange = (e) => {}; // create a lowlight instance with all languages loaded - const lowlight = createLowlight(hljs.listLanguages().reduce((obj, lang) => { - obj[lang] = () => hljs.getLanguage(lang); - return obj; - }, {} as Record)); + const lowlight = createLowlight( + hljs.listLanguages().reduce( + (obj, lang) => { + obj[lang] = () => hljs.getLanguage(lang); + return obj; + }, + {} as Record + ) + ); export let editor: Editor | null = null; @@ -501,9 +506,14 @@ export const focus = () => { if (editor) { - editor.view.focus(); - // Scroll to the current selection - editor.view.dispatch(editor.view.state.tr.scrollIntoView()); + try { + editor.view?.focus(); + // Scroll to the current selection + editor.view?.dispatch(editor.view.state.tr.scrollIntoView()); + } catch (e) { + // sometimes focusing throws an error, ignore + console.warn('Error focusing editor', e); + } } }; From f25a144e0979d8ba15535f10fd21334f4255e0e9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 24 Sep 2025 06:52:44 -0500 Subject: [PATCH 05/52] refac --- src/lib/components/layout/Overlay/AccountPending.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/layout/Overlay/AccountPending.svelte b/src/lib/components/layout/Overlay/AccountPending.svelte index 9197933b60..0c4dc8c2d5 100644 --- a/src/lib/components/layout/Overlay/AccountPending.svelte +++ b/src/lib/components/layout/Overlay/AccountPending.svelte @@ -1,4 +1,7 @@ + + +
+
+

+ {$i18n.t('Attach Webpage')} +

+ +
+ +
+
{ + e.preventDefault(); + submitHandler(); + }} + > +
+ +
+ + + +
+ +
+
+
+
+
diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index a8a44944c3..3c27ba31b6 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -24,6 +24,8 @@ import Chats from './InputMenu/Chats.svelte'; import Notes from './InputMenu/Notes.svelte'; import Knowledge from './InputMenu/Knowledge.svelte'; + import Link from '$lib/components/icons/Link.svelte'; + import AttachWebpageModal from './AttachWebpageModal.svelte'; const i18n = getContext('i18n'); @@ -39,11 +41,14 @@ export let uploadGoogleDriveHandler: Function; export let uploadOneDriveHandler: Function; + export let onUpload: Function; export let onClose: Function; let show = false; let tab = ''; + let showAttachWebpageModal = false; + let fileUploadEnabled = true; $: fileUploadEnabled = fileUploadCapableModels.length === selectedModels.length && @@ -78,6 +83,13 @@ }; + { + onUpload(e); + }} +/> + + { + showAttachWebpageModal = true; + }} + > + +
{$i18n.t('Attach Webpage')}
+
+ {#if $config?.features?.enable_notes ?? false} Date: Wed, 24 Sep 2025 11:20:39 -0500 Subject: [PATCH 19/52] refac --- src/lib/components/admin/Evaluations/Feedbacks.svelte | 2 +- src/lib/components/admin/Evaluations/Leaderboard.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index 97714b2971..62304088ed 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -202,7 +202,7 @@ {:else} - + - +
Date: Wed, 24 Sep 2025 11:22:48 -0500 Subject: [PATCH 20/52] refac --- src/lib/components/chat/MessageInput/InputMenu.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 3c27ba31b6..0aaee6e3ca 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -24,8 +24,8 @@ import Chats from './InputMenu/Chats.svelte'; import Notes from './InputMenu/Notes.svelte'; import Knowledge from './InputMenu/Knowledge.svelte'; - import Link from '$lib/components/icons/Link.svelte'; import AttachWebpageModal from './AttachWebpageModal.svelte'; + import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; const i18n = getContext('i18n'); @@ -184,7 +184,7 @@ showAttachWebpageModal = true; }} > - +
{$i18n.t('Attach Webpage')}
From 0dee15ba9767e4b123667df9eafe0b145ffcbe38 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 24 Sep 2025 11:27:19 -0500 Subject: [PATCH 21/52] refac/enh: include foldered chats in ref chat input menu --- backend/open_webui/models/chats.py | 7 ++++++- backend/open_webui/routers/chats.py | 10 +++++++--- src/lib/apis/chats/index.ts | 10 +++++++++- .../chat/MessageInput/InputMenu/Chats.svelte | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 5449d245d5..97fd9b6256 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -492,11 +492,16 @@ class ChatTable: self, user_id: str, include_archived: bool = False, + include_folders: bool = False, skip: Optional[int] = None, limit: Optional[int] = None, ) -> list[ChatTitleIdResponse]: with get_db() as db: - query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None) + query = db.query(Chat).filter_by(user_id=user_id) + + if not include_folders: + query = query.filter_by(folder_id=None) + query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) if not include_archived: diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 847368412e..788e355f2b 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -37,7 +37,9 @@ router = APIRouter() @router.get("/", response_model=list[ChatTitleIdResponse]) @router.get("/list", response_model=list[ChatTitleIdResponse]) def get_session_user_chat_list( - user=Depends(get_verified_user), page: Optional[int] = None + user=Depends(get_verified_user), + page: Optional[int] = None, + include_folders: Optional[bool] = False, ): try: if page is not None: @@ -45,10 +47,12 @@ def get_session_user_chat_list( skip = (page - 1) * limit return Chats.get_chat_title_id_list_by_user_id( - user.id, skip=skip, limit=limit + user.id, include_folders=include_folders, skip=skip, limit=limit ) else: - return Chats.get_chat_title_id_list_by_user_id(user.id) + return Chats.get_chat_title_id_list_by_user_id( + user.id, include_folders=include_folders + ) except Exception as e: log.exception(e) raise HTTPException( diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index b1e7d5f23b..59d8600771 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -77,7 +77,11 @@ export const importChat = async ( return res; }; -export const getChatList = async (token: string = '', page: number | null = null) => { +export const getChatList = async ( + token: string = '', + page: number | null = null, + include_folders: boolean = false +) => { let error = null; const searchParams = new URLSearchParams(); @@ -85,6 +89,10 @@ export const getChatList = async (token: string = '', page: number | null = null searchParams.append('page', `${page}`); } + if (include_folders) { + searchParams.append('include_folders', 'true'); + } + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, { method: 'GET', headers: { diff --git a/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte b/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte index b4ef49dce5..68688c83f9 100644 --- a/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte @@ -31,7 +31,7 @@ const getItemsPage = async () => { itemsLoading = true; - let res = await getChatList(localStorage.token, page).catch(() => { + let res = await getChatList(localStorage.token, page, true).catch(() => { return []; }); From b4d82879463ad8ee2d46fd99ec203998a35ed9f7 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:31:48 +0200 Subject: [PATCH 22/52] add youtube --- src/lib/components/chat/MessageInput/AttachWebpageModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte b/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte index 9105c10362..e3ea186d05 100644 --- a/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte +++ b/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte @@ -18,7 +18,7 @@ if (isValidHttpUrl(url)) { onSubmit({ type: - url.startsWith('https://www.youtube.com') || url.startsWith('https://youtu.be') + url.startsWith('https://www.youtube.com') || url.startsWith('https://youtu.be') || url.startsWith('https://youtube.com') || url.startsWith('https://m.youtube.com') ? 'youtube' : 'web', data: url From 05732de8980d837d8f61bc7b4ffa7305ebd9cd84 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 24 Sep 2025 11:36:17 -0500 Subject: [PATCH 23/52] refac --- .../chat/MessageInput/AttachWebpageModal.svelte | 7 ++----- .../chat/MessageInput/Commands/Knowledge.svelte | 6 +++--- src/lib/utils/index.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte b/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte index e3ea186d05..ae6f63ef22 100644 --- a/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte +++ b/src/lib/components/chat/MessageInput/AttachWebpageModal.svelte @@ -7,7 +7,7 @@ import Modal from '$lib/components/common/Modal.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; - import { isValidHttpUrl } from '$lib/utils'; + import { isValidHttpUrl, isYoutubeUrl } from '$lib/utils'; export let show = false; export let onSubmit: (e) => void; @@ -17,10 +17,7 @@ const submitHandler = () => { if (isValidHttpUrl(url)) { onSubmit({ - type: - url.startsWith('https://www.youtube.com') || url.startsWith('https://youtu.be') || url.startsWith('https://youtube.com') || url.startsWith('https://m.youtube.com') - ? 'youtube' - : 'web', + type: isYoutubeUrl(url) ? 'youtube' : 'web', data: url }); diff --git a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte index 5a6ce96cc4..077f97d416 100644 --- a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte @@ -7,7 +7,7 @@ dayjs.extend(relativeTime); import { tick, getContext, onMount, onDestroy } from 'svelte'; - import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; + import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import Database from '$lib/components/icons/Database.svelte'; @@ -36,7 +36,7 @@ : items), ...(query.startsWith('http') - ? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be') + ? isYoutubeUrl(query) ? [{ type: 'youtube', name: query, description: query }] : [ { @@ -228,7 +228,7 @@ {/if} {/each} - {#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')} + {#if isYoutubeUrl(query)} + + + {/if} +
{ - toast.error(`${error}`); - }); - } else if (type === 'function') { - res = await updateFunctionValvesById(localStorage.token, id, valves).catch((error) => { - toast.error(`${error}`); - }); + if (userValves) { + if (type === 'tool') { + res = await updateToolUserValvesById(localStorage.token, id, valves).catch((error) => { + toast.error(`${error}`); + }); + } else if (type === 'function') { + res = await updateFunctionUserValvesById(localStorage.token, id, valves).catch( + (error) => { + toast.error(`${error}`); + } + ); + } + } else { + if (type === 'tool') { + res = await updateToolValvesById(localStorage.token, id, valves).catch((error) => { + toast.error(`${error}`); + }); + } else if (type === 'function') { + res = await updateFunctionValvesById(localStorage.token, id, valves).catch((error) => { + toast.error(`${error}`); + }); + } } if (res) { @@ -67,28 +96,43 @@ valves = {}; valvesSpec = null; - if (type === 'tool') { - valves = await getToolValvesById(localStorage.token, id); - valvesSpec = await getToolValvesSpecById(localStorage.token, id); - } else if (type === 'function') { - valves = await getFunctionValvesById(localStorage.token, id); - valvesSpec = await getFunctionValvesSpecById(localStorage.token, id); - } - - if (!valves) { - valves = {}; - } - - if (valvesSpec) { - // Convert array to string - for (const property in valvesSpec.properties) { - if (valvesSpec.properties[property]?.type === 'array') { - valves[property] = (valves[property] ?? []).join(','); + try { + if (userValves) { + if (type === 'tool') { + valves = await getToolUserValvesById(localStorage.token, id); + valvesSpec = await getToolUserValvesSpecById(localStorage.token, id); + } else if (type === 'function') { + valves = await getFunctionUserValvesById(localStorage.token, id); + valvesSpec = await getFunctionUserValvesSpecById(localStorage.token, id); + } + } else { + if (type === 'tool') { + valves = await getToolValvesById(localStorage.token, id); + valvesSpec = await getToolValvesSpecById(localStorage.token, id); + } else if (type === 'function') { + valves = await getFunctionValvesById(localStorage.token, id); + valvesSpec = await getFunctionValvesSpecById(localStorage.token, id); } } - } - loading = false; + if (!valves) { + valves = {}; + } + + if (valvesSpec) { + // Convert array to string + for (const property in valvesSpec.properties) { + if (valvesSpec.properties[property]?.type === 'array') { + valves[property] = (valves[property] ?? []).join(','); + } + } + } + + loading = false; + } catch (e) { + toast.error(`Error fetching valves`); + show = false; + } }; $: if (show) { From 34d16791a8cfb0cbedb8567baa663237ecf7bc0f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 24 Sep 2025 23:27:45 -0500 Subject: [PATCH 42/52] refac --- backend/open_webui/main.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 212b1fac00..08a27331c9 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1884,7 +1884,6 @@ async def get_current_usage(user=Depends(get_verified_user)): # SessionMiddleware is used by authlib for oauth if len(OAUTH_PROVIDERS) > 0: try: - # Try to create Redis store for sessions if REDIS_URL: redis_session_store = RedisStore( url=REDIS_URL, @@ -1893,9 +1892,7 @@ if len(OAUTH_PROVIDERS) > 0: ), ) - # Add SessionAutoloadMiddleware first to handle session loading app.add_middleware(SessionAutoloadMiddleware) - app.add_middleware( StarSessionsMiddleware, store=redis_session_store, @@ -1903,15 +1900,10 @@ if len(OAUTH_PROVIDERS) > 0: cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE, cookie_https_only=WEBUI_SESSION_COOKIE_SECURE, ) - log.info("Using StarSessions with Redis for session management") + log.info("Using Redis for session") else: - raise ValueError("Redis URL not configured") - + raise ValueError("No Redis URL provided") except Exception as e: - log.warning( - f"Failed to initialize Redis sessions, falling back to cookie based sessions: {e}" - ) - # Fallback to existing SessionMiddleware app.add_middleware( SessionMiddleware, secret_key=WEBUI_SECRET_KEY, From 972be4eda5a394c111e849075f94099c9c0dd9aa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 25 Sep 2025 00:28:13 -0500 Subject: [PATCH 43/52] enh: oauth2.1 dynamic client registration --- backend/open_webui/env.py | 4 + backend/open_webui/routers/configs.py | 140 ++++-- backend/open_webui/utils/mcp/client.py | 8 +- backend/open_webui/utils/oauth.py | 464 ++++++++++++++++++- src/lib/apis/configs/index.ts | 36 ++ src/lib/components/AddToolServerModal.svelte | 139 ++++-- 6 files changed, 721 insertions(+), 70 deletions(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 243b8212a8..e02424f969 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -474,6 +474,10 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE = ( os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true" ) +OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get( + "OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY +) + OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get( "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY ) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 31d7bce404..809a1cc47d 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,6 +1,7 @@ import logging from fastapi import APIRouter, Depends, Request, HTTPException from pydantic import BaseModel, ConfigDict +import aiohttp from typing import Optional @@ -17,6 +18,12 @@ from open_webui.utils.mcp.client import MCPClient from open_webui.env import SRC_LOG_LEVELS +from open_webui.utils.oauth import ( + get_discovery_urls, + get_oauth_client_info_with_dynamic_client_registration, + encrypt_token, +) +from mcp.shared.auth import OAuthMetadata router = APIRouter() @@ -86,6 +93,38 @@ async def set_connections_config( } +class OAuthClientRegistrationForm(BaseModel): + url: str + client_id: str + client_name: Optional[str] = None + + +@router.post("/oauth/clients/register") +async def register_oauth_client( + request: Request, + form_data: OAuthClientRegistrationForm, + user=Depends(get_admin_user), +): + try: + oauth_client_info = ( + await get_oauth_client_info_with_dynamic_client_registration( + request, form_data.url + ) + ) + return { + "status": True, + "oauth_client_info": encrypt_token( + oauth_client_info.model_dump(mode="json") + ), + } + except Exception as e: + log.debug(f"Failed to register OAuth client: {e}") + raise HTTPException( + status_code=400, + detail=f"Failed to register OAuth client", + ) + + ############################ # ToolServers Config ############################ @@ -138,46 +177,79 @@ async def verify_tool_servers_config( """ try: if form_data.type == "mcp": - try: - client = MCPClient() - auth = None - headers = None + if form_data.auth_type == "oauth_2.1": + discovery_urls = get_discovery_urls(form_data.url) + async with aiohttp.ClientSession() as session: + async with session.get( + discovery_urls[0] + ) as oauth_server_metadata_response: + if oauth_server_metadata_response.status != 200: + raise HTTPException( + status_code=400, + detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}", + ) - token = None - if form_data.auth_type == "bearer": - token = form_data.key - elif form_data.auth_type == "session": - token = request.state.token.credentials - elif form_data.auth_type == "system_oauth": - try: - if request.cookies.get("oauth_session_id", None): - token = ( - await request.app.state.oauth_manager.get_oauth_token( + try: + oauth_server_metadata = OAuthMetadata.model_validate( + await oauth_server_metadata_response.json() + ) + return { + "status": True, + "oauth_server_metadata": oauth_server_metadata.model_dump( + mode="json" + ), + } + except Exception as e: + log.info( + f"Failed to parse OAuth 2.1 discovery document: {e}" + ) + raise HTTPException( + status_code=400, + detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}", + ) + + raise HTTPException( + status_code=400, + detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}", + ) + else: + try: + client = MCPClient() + headers = None + + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + elif form_data.auth_type == "system_oauth": + try: + if request.cookies.get("oauth_session_id", None): + token = await request.app.state.oauth_manager.get_oauth_token( user.id, request.cookies.get("oauth_session_id", None), ) - ) - except Exception as e: - pass + except Exception as e: + pass - if token: - headers = {"Authorization": f"Bearer {token}"} + if token: + headers = {"Authorization": f"Bearer {token}"} - await client.connect(form_data.url, auth=auth, headers=headers) - specs = await client.list_tool_specs() - return { - "status": True, - "specs": specs, - } - except Exception as e: - log.debug(f"Failed to create MCP client: {e}") - raise HTTPException( - status_code=400, - detail=f"Failed to create MCP client", - ) - finally: - if client: - await client.disconnect() + await client.connect(form_data.url, headers=headers) + specs = await client.list_tool_specs() + return { + "status": True, + "specs": specs, + } + except Exception as e: + log.debug(f"Failed to create MCP client: {e}") + raise HTTPException( + status_code=400, + detail=f"Failed to create MCP client", + ) + finally: + if client: + await client.disconnect() else: # openapi token = None if form_data.auth_type == "bearer": diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 2d352ead24..01df38886c 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -13,13 +13,9 @@ class MCPClient: self.session: Optional[ClientSession] = None self.exit_stack = AsyncExitStack() - async def connect( - self, url: str, headers: Optional[dict] = None, auth: Optional[any] = None - ): + async def connect(self, url: str, headers: Optional[dict] = None): try: - self._streams_context = streamablehttp_client( - url, headers=headers, auth=auth - ) + self._streams_context = streamablehttp_client(url, headers=headers) transport = await self.exit_stack.enter_async_context(self._streams_context) read_stream, write_stream, _ = transport diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 2147428443..6d5ea470cd 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1,7 +1,9 @@ import base64 +import hashlib import logging import mimetypes import sys +import urllib import uuid import json from datetime import datetime, timedelta @@ -9,6 +11,9 @@ from datetime import datetime, timedelta import re import fnmatch import time +import secrets +from cryptography.fernet import Fernet + import aiohttp from authlib.integrations.starlette_client import OAuth @@ -18,6 +23,7 @@ from fastapi import ( status, ) from starlette.responses import RedirectResponse +from typing import Optional from open_webui.models.auths import Auths @@ -56,11 +62,27 @@ from open_webui.env import ( WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, ENABLE_OAUTH_ID_TOKEN_COOKIE, + OAUTH_CLIENT_INFO_ENCRYPTION_KEY, ) from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.webhook import post_webhook +from mcp.shared.auth import ( + OAuthClientMetadata, + OAuthMetadata, +) + + +class OAuthClientInformationFull(OAuthClientMetadata): + issuer: Optional[str] = None # URL of the OAuth server that issued this client + + client_id: str + client_secret: str | None = None + client_id_issued_at: int | None = None + client_secret_expires_at: int | None = None + + from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -89,6 +111,42 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN +FERNET = None + +if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44: + key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest() + OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes) +else: + OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode() + +try: + FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) +except Exception as e: + log.error(f"Error initializing Fernet with provided key: {e}") + raise + + +def encrypt_token(token) -> str: + """Encrypt OAuth tokens for storage""" + try: + token_json = json.dumps(token) + encrypted = FERNET.encrypt(token_json.encode()).decode() + return encrypted + except Exception as e: + log.error(f"Error encrypting tokens: {e}") + raise + + +def decrypt_token(token: str): + """Decrypt OAuth tokens from storage""" + try: + decrypted = FERNET.decrypt(token.encode()).decode() + return json.loads(decrypted) + except Exception as e: + log.error(f"Error decrypting tokens: {e}") + raise + + def is_in_blocked_groups(group_name: str, groups: list) -> bool: """ Check if a group name matches any blocked pattern. @@ -133,6 +191,406 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool: return False +def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]: + parsed = urllib.parse.urlparse(server_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + return parsed, base_url + + +def get_discovery_urls(server_url) -> list[str]: + urls = [] + parsed, base_url = get_parsed_and_base_url(server_url) + + urls.append( + urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server") + ) + urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration")) + + return urls + + +# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration. +# This is not currently supported. +async def get_oauth_client_info_with_dynamic_client_registration( + request, oauth_server_url, oauth_server_key: Optional[str] = None +) -> OAuthClientInformationFull: + try: + oauth_server_metadata = None + oauth_server_metadata_url = None + + redirect_base_url = ( + str(request.app.state.config.WEBUI_URL or request.base_url) + ).rstrip("/") + oauth_client_metadata = OAuthClientMetadata( + client_name="Open WebUI", + redirect_uris=[f"{redirect_base_url}/oauth/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="client_secret_post", + ) + + # Attempt to fetch OAuth server metadata to get registration endpoint & scopes + discovery_urls = get_discovery_urls(oauth_server_url) + for url in discovery_urls: + async with aiohttp.ClientSession() as session: + async with session.get( + url, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as oauth_server_metadata_response: + if oauth_server_metadata_response.status == 200: + try: + oauth_server_metadata = OAuthMetadata.model_validate( + await oauth_server_metadata_response.json() + ) + oauth_server_metadata_url = url + if ( + oauth_client_metadata.scope is None + and oauth_server_metadata.scopes_supported is not None + ): + oauth_client_metadata.scope = " ".join( + oauth_server_metadata.scopes_supported + ) + break + except Exception as e: + log.error(f"Error parsing OAuth metadata from {url}: {e}") + continue + + registration_url = None + if oauth_server_metadata and oauth_server_metadata.registration_endpoint: + registration_url = str(oauth_server_metadata.registration_endpoint) + else: + _, base_url = get_parsed_and_base_url(oauth_server_url) + registration_url = urllib.parse.urljoin(base_url, "/register") + + registration_data = oauth_client_metadata.model_dump( + exclude_none=True, + mode="json", + by_alias=True, + ) + + # Perform dynamic client registration and return client info + async with aiohttp.ClientSession() as session: + async with session.post( + registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as oauth_client_registration_response: + try: + registration_response_json = ( + await oauth_client_registration_response.json() + ) + oauth_client_info = OAuthClientInformationFull.model_validate( + { + **registration_response_json, + **{"issuer": oauth_server_metadata_url}, + } + ) + log.info( + f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}" + ) + return oauth_client_info + except Exception as e: + error_text = None + try: + error_text = await oauth_client_registration_response.text() + log.error( + f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}" + ) + except Exception as e: + pass + + log.error(f"Error parsing client registration response: {e}") + raise Exception( + f"Dynamic client registration failed: {error_text}" + if error_text + else "Error parsing client registration response" + ) + raise Exception("Dynamic client registration failed") + except Exception as e: + log.error(f"Exception during dynamic client registration: {e}") + raise e + + +class OAuthClientManager: + def __init__(self, app): + self.oauth = OAuth() + self.app = app + self.clients = {} + + def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): + if client_id not in self.clients: + self.clients[client_id] = { + "client": self.oauth.register( + name=client_id, + client_id=oauth_client_info.client_id, + client_secret=oauth_client_info.client_secret, + client_kwargs=( + {"scope": oauth_client_info.scope} + if oauth_client_info.scope + else {} + ), + server_metadata_url=( + oauth_client_info.issuer if oauth_client_info.issuer else None + ), + ), + "client_info": oauth_client_info, + } + return self.clients[client_id] + + def remove_client(self, client_id): + if client_id in self.clients: + del self.clients[client_id] + log.info(f"Removed OAuth client {client_id}") + return True + + def get_client(self, client_id): + client = self.clients.get(client_id) + return client["client"] if client else None + + def get_client_info(self, client_id): + client = self.clients.get(client_id) + return client["client_info"] if client else None + + def get_server_metadata_url(self, client_id): + if client_id in self.clients: + client = self.clients[client_id] + return ( + client.server_metadata_url + if hasattr(client, "server_metadata_url") + else None + ) + return None + + async def get_oauth_token( + self, user_id: str, session_id: str, force_refresh: bool = False + ): + """ + Get a valid OAuth token for the user, automatically refreshing if needed. + + Args: + user_id: The user ID + session_id: The OAuth session ID + force_refresh: Force token refresh even if current token appears valid + + Returns: + dict: OAuth token data with access_token, or None if no valid token available + """ + try: + # Get the OAuth session + session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id) + if not session: + log.warning( + f"No OAuth session found for user {user_id}, session {session_id}" + ) + return None + + if force_refresh or datetime.now() + timedelta( + minutes=5 + ) >= datetime.fromtimestamp(session.expires_at): + log.debug( + f"Token refresh needed for user {user_id}, client_id {session.provider}" + ) + refreshed_token = await self._refresh_token(session) + if refreshed_token: + return refreshed_token + else: + log.warning( + f"Token refresh failed for user {user_id}, client_id {session.provider}" + ) + return None + return session.token + + except Exception as e: + log.error(f"Error getting OAuth token for user {user_id}: {e}") + return None + + async def _refresh_token(self, session) -> dict: + """ + Refresh an OAuth token if needed, with concurrency protection. + + Args: + session: The OAuth session object + + Returns: + dict: Refreshed token data, or None if refresh failed + """ + try: + # Perform the actual refresh + refreshed_token = await self._perform_token_refresh(session) + + if refreshed_token: + # Update the session with new token data + session = OAuthSessions.update_session_by_id( + session.id, refreshed_token + ) + log.info(f"Successfully refreshed token for session {session.id}") + return session.token + else: + log.error(f"Failed to refresh token for session {session.id}") + return None + + except Exception as e: + log.error(f"Error refreshing token for session {session.id}: {e}") + return None + + async def _perform_token_refresh(self, session) -> dict: + """ + Perform the actual OAuth token refresh. + + Args: + session: The OAuth session object + + Returns: + dict: New token data, or None if refresh failed + """ + client_id = session.provider + token_data = session.token + + if not token_data.get("refresh_token"): + log.warning(f"No refresh token available for session {session.id}") + return None + + try: + client = self.get_client(client_id) + if not client: + log.error(f"No OAuth client found for provider {client_id}") + return None + + token_endpoint = None + async with aiohttp.ClientSession(trust_env=True) as session_http: + async with session_http.get( + self.get_server_metadata_url(client_id) + ) as r: + if r.status == 200: + openid_data = await r.json() + token_endpoint = openid_data.get("token_endpoint") + else: + log.error( + f"Failed to fetch OpenID configuration for client_id {client_id}" + ) + if not token_endpoint: + log.error(f"No token endpoint found for client_id {client_id}") + return None + + # Prepare refresh request + refresh_data = { + "grant_type": "refresh_token", + "refresh_token": token_data["refresh_token"], + "client_id": client.client_id, + } + if hasattr(client, "client_secret") and client.client_secret: + refresh_data["client_secret"] = client.client_secret + + # Make refresh request + async with aiohttp.ClientSession(trust_env=True) as session_http: + async with session_http.post( + token_endpoint, + data=refresh_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status == 200: + new_token_data = await r.json() + + # Merge with existing token data (preserve refresh_token if not provided) + if "refresh_token" not in new_token_data: + new_token_data["refresh_token"] = token_data[ + "refresh_token" + ] + + # Add timestamp for tracking + new_token_data["issued_at"] = datetime.now().timestamp() + + # Calculate expires_at if we have expires_in + if ( + "expires_in" in new_token_data + and "expires_at" not in new_token_data + ): + new_token_data["expires_at"] = int( + datetime.now().timestamp() + + new_token_data["expires_in"] + ) + + log.debug(f"Token refresh successful for client_id {client_id}") + return new_token_data + else: + error_text = await r.text() + log.error( + f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}" + ) + return None + + except Exception as e: + log.error(f"Exception during token refresh for client_id {client_id}: {e}") + return None + + async def handle_authorize(self, request, client_id: str) -> RedirectResponse: + client = self.get_client(client_id) + if client is None: + raise HTTPException(404) + + client_info = self.get_client_info(client_id) + if client_info is None: + raise HTTPException(404) + + redirect_uri = ( + client_info.redirect_uris[0] if client_info.redirect_uris else None + ) + return await client.authorize_redirect(request, redirect_uri) + + async def handle_callback(self, request, client_id: str, user_id: str, response): + client = self.get_client(client_id) + if client is None: + raise HTTPException(404) + + error_message = None + try: + token = await client.authorize_access_token(request) + if token: + try: + # Add timestamp for tracking + token["issued_at"] = datetime.now().timestamp() + + # Calculate expires_at if we have expires_in + if "expires_in" in token and "expires_at" not in token: + token["expires_at"] = ( + datetime.now().timestamp() + token["expires_in"] + ) + + # Clean up any existing sessions for this user/client_id first + sessions = OAuthSessions.get_sessions_by_user_id(user_id) + for session in sessions: + if session.provider == client_id: + OAuthSessions.delete_session_by_id(session.id) + + session = OAuthSessions.create_session( + user_id=user_id, + provider=client_id, + token=token, + ) + + log.info( + f"Stored OAuth session server-side for user {user_id}, client_id {client_id}" + ) + except Exception as e: + error_message = "Failed to store OAuth session server-side" + log.error(f"Failed to store OAuth session server-side: {e}") + else: + error_message = "Failed to obtain OAuth token" + log.warning(error_message) + except Exception as e: + error_message = "OAuth callback error" + log.warning(f"OAuth callback error: {e}") + + redirect_base_url = ( + str(request.app.state.config.WEBUI_URL or request.base_url) + ).rstrip("/") + redirect_url = f"{redirect_base_url}/auth" + + if error_message: + redirect_url = f"{redirect_url}?error={error_message}" + return RedirectResponse(url=redirect_url, headers=response.headers) + + response = RedirectResponse(url=redirect_url, headers=response.headers) + + class OAuthManager: def __init__(self, app): self.oauth = OAuth() @@ -792,9 +1250,9 @@ class OAuthManager: else ERROR_MESSAGES.DEFAULT("Error during OAuth process") ) - redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url) - if redirect_base_url.endswith("/"): - redirect_base_url = redirect_base_url[:-1] + redirect_base_url = ( + str(request.app.state.config.WEBUI_URL or request.base_url) + ).rstrip("/") redirect_url = f"{redirect_base_url}/auth" if error_message: diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index ef983e63bf..77374c93b6 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -202,6 +202,42 @@ export const verifyToolServerConnection = async (token: string, connection: obje return res; }; +type RegisterOAuthClientForm = { + url: string; + client_id: string; + client_name?: string; +}; + +export const registerOAuthClient = async (token: string, formData: RegisterOAuthClientForm) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...formData + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getCodeExecutionConfig = async (token: string) => { let error = null; diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 01c87010ef..fea4551b3a 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -13,7 +13,7 @@ import Switch from '$lib/components/common/Switch.svelte'; import Tags from './common/Tags.svelte'; import { getToolServerData } from '$lib/apis'; - import { verifyToolServerConnection } from '$lib/apis/configs'; + import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs'; import AccessControl from './workspace/common/AccessControl.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; @@ -41,10 +41,37 @@ let name = ''; let description = ''; - let enable = true; + let oauthClientInfo = null; + let enable = true; let loading = false; + const registerOAuthClientHandler = async () => { + if (url === '') { + toast.error($i18n.t('Please enter a valid URL')); + return; + } + + if (id === '') { + toast.error($i18n.t('Please enter a valid ID')); + return; + } + + const res = await registerOAuthClient(localStorage.token, { + url: url, + client_id: id + }).catch((err) => { + toast.error($i18n.t('Registration failed')); + return null; + }); + + if (res) { + toast.success($i18n.t('Registration successful')); + console.debug('Registration successful', res); + oauthClientInfo = res?.oauth_client_info ?? null; + } + }; + const verifyHandler = async () => { if (url === '') { toast.error($i18n.t('Please enter a valid URL')); @@ -106,6 +133,12 @@ return; } + if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) { + toast.error($i18n.t('Please register the OAuth client')); + loading = false; + return; + } + const connection = { url, path, @@ -119,7 +152,8 @@ info: { id: id, name: name, - description: description + description: description, + ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}) } }; @@ -139,6 +173,7 @@ id = ''; name = ''; description = ''; + oauthClientInfo = null; enable = true; accessControl = null; @@ -156,6 +191,7 @@ id = connection.info?.id ?? ''; name = connection.info?.name ?? ''; description = connection.info?.description ?? ''; + oauthClientInfo = connection.info?.oauth_client_info ?? null; enable = connection.config?.enable ?? true; accessControl = connection.config?.access_control ?? null; @@ -227,25 +263,6 @@
{/if} - {#if type === 'mcp'} -
- - {$i18n.t('Warning')}: - - {$i18n.t( - '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.' - )} - - {$i18n.t('Read more →')} -
- {/if} -
@@ -333,11 +350,51 @@
- +
+
+
+ {$i18n.t('Auth')} +
+
+ + {#if auth_type === 'oauth_2.1'} +
+ {#if !oauthClientInfo} +
+ {$i18n.t('Not Registered')} +
+ {:else} +
+ {$i18n.t('Registered')} +
+ {/if} +
+ + + +
+
+ {/if} +
@@ -353,6 +410,9 @@ {#if !direct} + {#if type === 'mcp'} + + {/if} {/if}
@@ -382,6 +442,12 @@ > {$i18n.t('Forwards system user OAuth access token to authenticate')}
+ {:else if auth_type === 'oauth_2.1'} +
+ {$i18n.t('Uses ​OAuth 2.1 Dynamic Client Registration to authenticate')} +
{/if}
@@ -470,6 +536,25 @@ {/if}
+ {#if type === 'mcp'} +
+ + {$i18n.t('Warning')}: + + {$i18n.t( + '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.' + )} + + {$i18n.t('Read more →')} +
+ {/if} +
{#if edit}