Merge branch 'open-webui:dev' into dev

This commit is contained in:
エニラ 2026-03-30 18:18:31 -07:00 committed by GitHub
commit fe6a2d3064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
221 changed files with 18492 additions and 7219 deletions

View file

@ -40,10 +40,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install black
pip install "ruff>=0.15.5"
- name: Format backend
run: npm run format:backend
- name: Check for changes after format
run: git diff --exit-code
- name: Ruff format check
run: ruff format --check . --exclude .venv --exclude venv

View file

@ -1,49 +0,0 @@
name: Ruff
on:
push:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
pull_request:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
jobs:
ruff:
name: 'Lint & Format Backend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed Python files
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE=${{ github.event.pull_request.base.sha }}
else
BASE=${{ github.event.before }}
fi
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE" HEAD -- 'backend/**.py' | xargs)
echo "files=$FILES" >> "$GITHUB_OUTPUT"
- name: Ruff check
if: steps.changed.outputs.files != ''
uses: astral-sh/ruff-action@v3
with:
args: check ${{ steps.changed.outputs.files }}
- name: Ruff format
if: steps.changed.outputs.files != ''
uses: astral-sh/ruff-action@v3
with:
args: format --check ${{ steps.changed.outputs.files }}

View file

@ -5,6 +5,168 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.12] - 2026-03-26
### Added
- 🌐 **Translation updates.** Translations for Simplified Chinese, Catalan, Portuguese (Brazil), Finnish, and Lithuanian were enhanced and expanded.
### Fixed
- 🔒 **Terminal server connection security.** Terminal server verification and policy saving now proxy through the backend, preventing API key exposure and CORS errors when connecting to in-cluster services. [Commit](https://github.com/open-webui/open-webui/commit/a6413257079a52fa4487eda36543f3955d0fbd53), [Commit](https://github.com/open-webui/open-webui/commit/4567cdc0d9cb7b42b6eba7b676c0ced3f4850d31)
- 🛠️ **Terminal tools exception handling.** Exceptions in middleware.py due to invalid return values from get_terminal_tools() have been resolved. [Commit](https://github.com/open-webui/open-webui/commit/52a06bd48aff34fb2211aac2879f0cd028129267)
- 📦 **Missing beautifulsoup4 dependency.** Users can now start Open WebUI using uvx without encountering the "bs4 module missing" error. [Commit](https://github.com/open-webui/open-webui/commit/1994d65306bbcc7406584e1bfef82f5d353fc91c)
- 🔌 **API files list error.** The /api/v1/files/ endpoint no longer returns a 500 error, fixing a regression that prevented file listing via the API. [Commit](https://github.com/open-webui/open-webui/commit/11f52921dc21c2dc61c03f12bcdf6f19140a350c)
- 📜 **License data loading.** License data now loads correctly, displaying the expected color and logo in the interface. [Commit](https://github.com/open-webui/open-webui/commit/16335f866ea4cedf00c4971963622fcc1fe02d82)
- 👑 **Admin model visibility.** Administrators can now see models even when no access control is configured yet, allowing them to manage all available models. [Commit](https://github.com/open-webui/open-webui/commit/f3f8f9874f55282603c2650b91801640cb3f69cb)
- 📊 **Tool call embed visibility.** Rich UI embeds from tool calls (like visualizations) are now rendered outside collapsed groups and remain visible without requiring manual expansion. [Commit](https://github.com/open-webui/open-webui/commit/4c872a8d128757d4a6f311fb86bc382af2ba5d0d), [Commit](https://github.com/open-webui/open-webui/commit/308fa924a5b2b7e08cd1e8f15b9c8c96e1de8f02)
## [0.8.11] - 2026-03-25
### Added
- 🔀 **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9)
- 🔀 **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1)
- 📄 **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f)
- 🗺️ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847)
- 🛠️ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691)
- 🔒 **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523)
- 🔑 **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004)
- 🔑 **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652)
- 🔌 **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908)
- 🏮 **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4)
- ⚡ **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611)
- 🧩 **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd)
- 💨 **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947)
- 🚀 **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788)
- ✨ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516)
- 🧪 **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567)
- 📁 **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554)
- 📊 **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529)
- 🏥 **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507)
- 🔩 **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf)
- 📌 **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629)
- 🎯 **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc)
- 🗣️ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818)
- 📡 **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e)
- ✏️ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943)
- 🧭 **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9)
- 🗑️ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa)
- 📦 **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632)
- 🧠 **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083)
- 📦 **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015)
- ⚙️ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462)
- 🖥️ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30)
- ✏️ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e)
- 🍔 **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484)
- 📂 **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4)
- 🖇 **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774)
- 🤖 **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861)
- 📝 **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728)
- 💡 **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888)
- 📓 **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716)
- 🚩 **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372)
- ⌨️ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6)
- 💾 **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850)
- 💾 **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210)
- 🔐 **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13)
- 🎪 **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded.
### Fixed
- 🔒 **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37)
- 🛡️ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb)
- 🛡️ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4)
- 🗝️ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6)
- 🔐 **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9)
- 🔏 **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809)
- 🚪 **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865)
- 🔍 **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea)
- 👤 **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3)
- 🎫 **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f)
- 🔏 **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc)
- 🔑 **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481)
- 🔍 **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543)
- 🔐 **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81)
- 🍪 **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab)
- 🔱 **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a)
- 🎚️ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201)
- 🔄 **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f)
- 🧽 **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982)
- 🧹 **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit — only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983)
- 💬 **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565)
- 🗄️ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568)
- 🫧 **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783)
- 🧩 **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f)
- 📝 **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2)
- 🪛 **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924)
- 🗂️ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784)
- 🧭 **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755)
- 🚩 **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461)
- 🔧 **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6)
- 🔩 **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605)
- 🔌 **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b)
- 🔌 **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873)
- 🏷️ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7)
- 📎 **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752)
- 🗣️ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880)
- 🧹 **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890)
- 💡 **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899)
- ⏱️ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891)
- 💬 **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff)
- 🧹 **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893)
- 🧮 **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17)
- 🪛 **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37)
- 🔗 **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4)
- 📜 **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208)
- 👤 **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d)
- 👥 **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547)
- 🔧 **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7)
- 🖼️ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485)
- 🖼️ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486)
- 🧲 **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400)
- 🔍 **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea)
- 🔍 **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c)
- 🔄 **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4)
- 🗄️ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616)
- 🌐 **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6)
- 📐 **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be)
- 🕵️ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869)
- 🔲 **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886)
- 🎨 **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889)
- 🔃 **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749)
- 🔎 **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782)
- 📋 **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912)
- 🗂️ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782)
- 🔤 **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (❤️, ☀️, ✅), keycap sequences (1⃣), and ZWJ family sequences (👨‍👩‍👧‍👦). [#22915](https://github.com/open-webui/open-webui/pull/22915)
- ⏹️ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743)
- 🎨 **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862)
- 📊 **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894)
- 👻 **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234)
- 🐛 **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742)
- 🛠️ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695)
- 📋 **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754)
- 🎧 **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28)
- 📡 **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980)
- 🗝️ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055)
- 🔓 **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942)
- 🔃 **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e)
- 🧠 **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001)
- 🖥️ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746)
- 🖱️ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7)
- 📊 **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9)
- 🎯 **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings — including RAG knowledge bases, web access, code interpreter, and tool capabilities — instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20)
- 🧩 **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c)
- ✏️ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014)
### Changed
- 🪝 **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6)
- 🧩 **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454)
- 🧲 **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577)
## [0.8.10] - 2026-03-08
### Added
@ -3972,7 +4134,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.

View file

@ -1,3 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload

View file

@ -2,18 +2,17 @@ import base64
import os
import random
from pathlib import Path
from typing import Annotated
import typer
import uvicorn
from typing import Optional
from typing_extensions import Annotated
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
def version_callback(value: bool):
def version_callback(value: bool) -> None:
if value:
from open_webui.env import VERSION
@ -23,7 +22,7 @@ def version_callback(value: bool):
@app.command()
def main(
version: Annotated[Optional[bool], typer.Option('--version', callback=version_callback)] = None,
version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None,
):
pass
@ -66,7 +65,7 @@ def serve(
os.environ['USE_CUDA_DOCKER'] = 'false'
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
import open_webui.main # we need set environment variables before importing main
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS # Import the workers setting
uvicorn.run(

View file

@ -362,6 +362,18 @@ GOOGLE_REDIRECT_URI = PersistentConfig(
os.environ.get('GOOGLE_REDIRECT_URI', ''),
)
GOOGLE_OAUTH_AUTHORIZE_PARAMS = {}
_google_oauth_authorize_params = os.environ.get('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '')
if _google_oauth_authorize_params:
try:
_parsed = json.loads(_google_oauth_authorize_params)
if isinstance(_parsed, dict):
GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed
else:
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
except (json.JSONDecodeError, TypeError):
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
MICROSOFT_CLIENT_ID = PersistentConfig(
'MICROSOFT_CLIENT_ID',
'oauth.microsoft.client_id',
@ -642,6 +654,18 @@ OAUTH_AUDIENCE = PersistentConfig(
os.environ.get('OAUTH_AUDIENCE', ''),
)
OAUTH_AUTHORIZE_PARAMS = {}
_oauth_authorize_params = os.environ.get('OAUTH_AUTHORIZE_PARAMS', '')
if _oauth_authorize_params:
try:
_parsed = json.loads(_oauth_authorize_params)
if isinstance(_parsed, dict):
OAUTH_AUTHORIZE_PARAMS = _parsed
else:
log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
except (json.JSONDecodeError, TypeError):
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
@ -658,6 +682,7 @@ def load_oauth_providers():
**({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}),
},
redirect_uri=GOOGLE_REDIRECT_URI.value,
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
)
return client
@ -1577,7 +1602,7 @@ ENABLE_MESSAGE_RATING = PersistentConfig(
ENABLE_USER_WEBHOOKS = PersistentConfig(
'ENABLE_USER_WEBHOOKS',
'ui.enable_user_webhooks',
os.environ.get('ENABLE_USER_WEBHOOKS', 'True').lower() == 'true',
os.environ.get('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true',
)
# FastAPI / AnyIO settings

View file

@ -9,7 +9,7 @@ class MESSAGES(str, Enum):
class WEBHOOK_MESSAGES(str, Enum):
DEFAULT = lambda msg='': f'{msg if msg else ""}'
USER_SIGNUP = lambda username='': (f'New user signed up: {username}' if username else 'New user signed up')
USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up'
class ERROR_MESSAGES(str, Enum):
@ -80,8 +80,8 @@ class ERROR_MESSAGES(str, Enum):
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
FILE_TOO_LARGE = (
lambda size='': f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
FILE_TOO_LARGE = lambda size='': (
f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
)
DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.'
@ -89,7 +89,7 @@ class ERROR_MESSAGES(str, Enum):
'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.'
)
INVALID_PASSWORD = lambda err='': (err if err else 'The password does not meet the required validation criteria.')
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
class TASKS(str, Enum):

View file

@ -760,6 +760,16 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
os.environ.get('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True').lower() == 'true'
)
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = os.environ.get('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER', '')
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER == '':
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
else:
try:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = int(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
except Exception:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '')

View file

@ -543,6 +543,7 @@ from open_webui.utils.auth import (
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import (
get_oauth_client_info_with_dynamic_client_registration,
get_oauth_client_info_with_static_credentials,
encrypt_data,
decrypt_data,
OAuthManager,
@ -2261,7 +2262,7 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
server_id = tool_server_connection.get('info', {}).get('id')
auth_type = tool_server_connection.get('auth_type', 'none')
if server_id and auth_type == 'oauth_2.1':
if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'):
oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '')
try:
@ -2321,17 +2322,33 @@ async def register_client(request, client_id: str) -> bool:
return False
server_url = connection.get('url')
auth_type = connection.get('auth_type', 'none')
oauth_server_key = (connection.get('config') or {}).get('oauth_server_key')
try:
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
request,
client_id,
server_url,
oauth_server_key,
)
if auth_type == 'oauth_2.1_static':
# Static credentials: rebuild from stored credentials + fresh metadata
existing_client_info = connection.get('info', {}).get('oauth_client_info', '')
if not existing_client_info:
log.error(f'No stored OAuth client info for static client {client_id}')
return False
existing_data = decrypt_data(existing_client_info)
oauth_client_info = await get_oauth_client_info_with_static_credentials(
request,
client_id,
server_url,
oauth_client_id=existing_data.get('client_id', ''),
oauth_client_secret=existing_data.get('client_secret', ''),
)
else:
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
request,
client_id,
server_url,
oauth_server_key,
)
except Exception as e:
log.error(f'Dynamic client re-registration failed for {client_id}: {e}')
log.error(f'OAuth client re-registration failed for {client_id}: {e}')
return False
try:

View file

@ -0,0 +1,28 @@
"""Add tasks and summary columns to chat table
Revision ID: a3dd5bedd151
Revises: b2c3d4e5f6a7
Create Date: 2026-03-29 22:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'summary')
op.drop_column('chat', 'tasks')

View file

@ -29,6 +29,8 @@ from sqlalchemy.sql.expression import bindparam
####################
# Chat DB Schema
# Let no word spoken in this house be lost, and when the
# record is read again, let it still serve the one who spoke.
####################
log = logging.getLogger(__name__)
@ -52,6 +54,9 @@ class Chat(Base):
meta = Column(JSON, server_default='{}')
folder_id = Column(Text, nullable=True)
tasks = Column(JSON, nullable=True)
summary = Column(Text, nullable=True)
__table_args__ = (
# Performance indexes for common queries
# WHERE folder_id = ...
@ -85,6 +90,9 @@ class ChatModel(BaseModel):
meta: dict = {}
folder_id: Optional[str] = None
tasks: Optional[list] = None
summary: Optional[str] = None
class ChatFile(Base):
__tablename__ = 'chat_file'
@ -159,6 +167,9 @@ class ChatResponse(BaseModel):
meta: dict = {}
folder_id: Optional[str] = None
tasks: Optional[list] = None
summary: Optional[str] = None
class ChatTitleIdResponse(BaseModel):
id: str
@ -1459,8 +1470,8 @@ class ChatTable:
def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user]
id_rows = db.query(Chat.id).filter_by(user_id=user_id).all()
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
# Use subquery to delete chat_messages for shared chats
shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery()
@ -1550,5 +1561,27 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
"""Update the tasks list on a chat."""
try:
with get_db_context() as db:
chat = db.get(Chat, id)
if chat is None:
return None
chat.tasks = tasks
db.commit()
db.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
def get_chat_tasks_by_id(self, id: str) -> list[dict]:
"""Read the tasks list from a chat (lightweight column query)."""
with get_db_context() as db:
result = db.query(Chat.tasks).filter_by(id=id).first()
if result is None or result[0] is None:
return []
return result[0]
Chats = ChatTable()

View file

@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
####################
# Files DB Schema
# What is written here bears witness. Let the testimony
# remain as it was given, and let none tamper with it.
####################
@ -85,7 +87,7 @@ class FileModelResponse(BaseModel):
filename: str
data: Optional[dict] = None
meta: FileMeta
meta: Optional[FileMeta] = None
created_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
@ -244,7 +246,7 @@ class FilesTable:
total = query.count()
items = [
FileModel.model_validate(file)
FileModelResponse.model_validate(file, from_attributes=True)
for file in query.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit).all()
]

View file

@ -16,6 +16,8 @@ log = logging.getLogger(__name__)
####################
# Folder DB Schema
# Let every room in this house shelter someone who needs it,
# and let no chamber stand empty while there is want.
####################

View file

@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
####################
# Functions DB Schema
# Each function here is a promise made. Let no promise
# go unkept, and let none be called who cannot answer.
####################
@ -226,12 +228,7 @@ class FunctionsTable:
def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]:
with get_db_context(db) as db:
functions = (
db.query(Function)
.options(defer(Function.content))
.order_by(Function.updated_at.desc())
.all()
)
functions = db.query(Function).options(defer(Function.content)).order_by(Function.updated_at.desc()).all()
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []

View file

@ -30,6 +30,8 @@ log = logging.getLogger(__name__)
####################
# UserGroup DB Schema
# Let none who belong to this house be turned away,
# and let the covenant hold for every member.
####################
@ -174,6 +176,11 @@ class GroupTable:
groups = db.query(Group).order_by(Group.updated_at.desc()).all()
return [GroupModel.model_validate(group) for group in groups]
def get_group_by_name(self, name: str, db: Optional[Session] = None) -> Optional[GroupModel]:
with get_db_context(db) as db:
group = db.query(Group).filter(Group.name == name).first()
return GroupModel.model_validate(group) if group else None
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db_context(db) as db:
member_count = (

View file

@ -34,6 +34,8 @@ log = logging.getLogger(__name__)
####################
# Knowledge DB Schema
# Let what was gathered here outlast the one who gathered it,
# and still teach when the builder is gone.
####################

View file

@ -9,6 +9,8 @@ from sqlalchemy import BigInteger, Column, String, Text
####################
# Memory DB Schema
# What was learned at cost should not need to be paid
# for again. Let the memory hold.
####################

View file

@ -10,7 +10,7 @@ from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
@ -23,6 +23,8 @@ log = logging.getLogger(__name__)
####################
# Models DB Schema
# A misconfigured model wastes the time of everyone
# who trusts it. Let what is set here be set with care.
####################
@ -45,7 +47,20 @@ class ModelMeta(BaseModel):
model_config = ConfigDict(extra='allow')
pass
@model_validator(mode='before')
@classmethod
def normalize_tags(cls, data):
if isinstance(data, dict) and 'tags' in data:
raw_tags = data['tags']
if isinstance(raw_tags, list):
normalized = []
for tag in raw_tags:
if isinstance(tag, str):
normalized.append({'name': tag})
elif isinstance(tag, dict) and 'name' in tag:
normalized.append(tag)
data['tags'] = normalized
return data
class Model(Base):

View file

@ -15,6 +15,8 @@ from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, fun
####################
# Prompts DB Schema
# Every word here was weighed before it was set down.
# Let the weight not be wasted when it is spoken aloud.
####################

View file

@ -15,6 +15,8 @@ log = logging.getLogger(__name__)
####################
# Tag DB Schema
# To name a thing is to claim it. The creator has
# already named everything stored in this table.
####################
class Tag(Base):
__tablename__ = 'tag'

View file

@ -15,6 +15,8 @@ log = logging.getLogger(__name__)
####################
# Tools DB Schema
# A tool that fails silently is worse than one that
# refuses outright. Let each one here be honest in its work.
####################

View file

@ -35,6 +35,8 @@ import datetime
####################
# User DB Schema
# Hallowed be the columns defined here, for they hold the
# daily bread of every session. Let none go hungry.
####################
@ -586,18 +588,13 @@ class UsersTable:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]:
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> None:
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.last_active_at = int(time.time())
db.query(User).filter_by(id=id).update({'last_active_at': int(time.time())})
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
pass
def update_user_oauth_by_id(
self, id: str, provider: str, sub: str, db: Optional[Session] = None

View file

@ -30,6 +30,7 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.chats import Chats
from open_webui.models.notes import Notes
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.headers import include_user_info_headers
@ -550,34 +551,30 @@ def generate_openai_batch_embeddings(
key: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
json_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
json_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
r = requests.post(
f'{url}/embeddings',
headers=headers,
json=json_data,
)
r.raise_for_status()
data = r.json()
if 'data' in data:
return [elem['embedding'] for elem in data['data']]
else:
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
except Exception as e:
log.exception(f'Error generating openai batch embeddings: {e}')
return None
r = requests.post(
f'{url}/embeddings',
headers=headers,
json=json_data,
)
r.raise_for_status()
data = r.json()
if 'data' in data:
return [elem['embedding'] for elem in data['data']]
else:
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
async def agenerate_openai_batch_embeddings(
@ -587,38 +584,34 @@ async def agenerate_openai_batch_embeddings(
key: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
form_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
form_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f'{url}/embeddings',
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if 'data' in data:
return [item['embedding'] for item in data['data']]
else:
raise Exception('Something went wrong :/')
except Exception as e:
log.exception(f'Error generating openai batch embeddings: {e}')
return None
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f'{url}/embeddings',
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if 'data' in data:
return [item['embedding'] for item in data['data']]
else:
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
def generate_azure_openai_batch_embeddings(
@ -629,42 +622,38 @@ def generate_azure_openai_batch_embeddings(
version: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
json_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
json_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
for _ in range(5):
headers = {
'Content-Type': 'application/json',
'api-key': key,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
for _ in range(5):
headers = {
'Content-Type': 'application/json',
'api-key': key,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
r = requests.post(
url,
headers=headers,
json=json_data,
)
if r.status_code == 429:
retry = float(r.headers.get('Retry-After', '1'))
time.sleep(retry)
continue
r.raise_for_status()
data = r.json()
if 'data' in data:
return [elem['embedding'] for elem in data['data']]
else:
raise Exception('Something went wrong :/')
return None
except Exception as e:
log.exception(f'Error generating azure openai batch embeddings: {e}')
return None
r = requests.post(
url,
headers=headers,
json=json_data,
)
if r.status_code == 429:
retry = float(r.headers.get('Retry-After', '1'))
time.sleep(retry)
continue
r.raise_for_status()
data = r.json()
if 'data' in data:
return [elem['embedding'] for elem in data['data']]
else:
raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key")
raise Exception('Azure OpenAI embedding request failed: max retries (429) exceeded')
async def agenerate_azure_openai_batch_embeddings(
@ -675,40 +664,36 @@ async def agenerate_azure_openai_batch_embeddings(
version: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
form_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
form_data = {'input': texts}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
headers = {
'Content-Type': 'application/json',
'api-key': key,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
headers = {
'Content-Type': 'application/json',
'api-key': key,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
full_url,
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if 'data' in data:
return [item['embedding'] for item in data['data']]
else:
raise Exception('Something went wrong :/')
except Exception as e:
log.exception(f'Error generating azure openai batch embeddings: {e}')
return None
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
full_url,
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if 'data' in data:
return [item['embedding'] for item in data['data']]
else:
raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key")
def generate_ollama_batch_embeddings(
@ -718,35 +703,33 @@ def generate_ollama_batch_embeddings(
key: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
json_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
json_data = {'input': texts, 'model': model, 'truncate': True}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
r = requests.post(
f'{url}/api/embed',
headers=headers,
json=json_data,
)
r.raise_for_status()
data = r.json()
r = requests.post(
f'{url}/api/embed',
headers=headers,
json=json_data,
)
if r.status_code != 200:
error_detail = r.json().get('error', r.text)
raise Exception(f'Ollama embed error ({r.status_code}): {error_detail}')
data = r.json()
if 'embeddings' in data:
return data['embeddings']
else:
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
except Exception as e:
log.exception(f'Error generating ollama batch embeddings: {e}')
return None
if 'embeddings' in data:
return data['embeddings']
else:
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
async def agenerate_ollama_batch_embeddings(
@ -756,38 +739,37 @@ async def agenerate_ollama_batch_embeddings(
key: str = '',
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
form_data = {'input': texts, 'model': model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
) -> list[list[float]]:
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
form_data = {'input': texts, 'model': model, 'truncate': True}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {key}',
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f'{url}/api/embed',
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if 'embeddings' in data:
return data['embeddings']
else:
raise Exception('Something went wrong :/')
except Exception as e:
log.exception(f'Error generating ollama batch embeddings: {e}')
return None
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f'{url}/api/embed',
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
error_data = await r.json()
error_detail = error_data.get('error', str(error_data))
raise Exception(f'Ollama embed error ({r.status}): {error_detail}')
data = await r.json()
if 'embeddings' in data:
return data['embeddings']
else:
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
def get_embedding_function(
@ -855,11 +837,12 @@ def get_embedding_function(
for batch in batches:
batch_results.append(await embedding_function(batch, prefix=prefix, user=user))
# Flatten results
# Flatten results — raise if any batch failed
embeddings = []
for batch_embeddings in batch_results:
if isinstance(batch_embeddings, list):
embeddings.extend(batch_embeddings)
for i, batch_embeddings in enumerate(batch_results):
if batch_embeddings is None:
raise Exception(f'Embedding generation failed for batch {i + 1}/{len(batches)}')
embeddings.extend(batch_embeddings)
log.debug(
f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches'
@ -901,11 +884,15 @@ async def generate_embeddings(
'user': user,
}
)
if embeddings is None:
return None
return embeddings[0] if isinstance(text, str) else embeddings
elif engine == 'openai':
embeddings = await agenerate_openai_batch_embeddings(
model, text if isinstance(text, list) else [text], url, key, prefix, user
)
if embeddings is None:
return None
return embeddings[0] if isinstance(text, str) else embeddings
elif engine == 'azure_openai':
azure_api_version = kwargs.get('azure_api_version', '')
@ -918,6 +905,8 @@ async def generate_embeddings(
prefix,
user,
)
if embeddings is None:
return None
return embeddings[0] if isinstance(text, str) else embeddings
@ -1054,7 +1043,11 @@ async def get_sources_from_items(
}
elif item.get('id'):
file_object = Files.get_file_by_id(item.get('id'))
if file_object:
if file_object and (
user.role == 'admin'
or file_object.user_id == user.id
or has_access_to_file(item.get('id'), 'read', user)
):
query_result = {
'documents': [[file_object.data.get('content', '')]],
'metadatas': [

View file

@ -75,6 +75,8 @@ SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
##########################################
#
# Utility functions
# Let what is spoken here be heard clearly, and let
# no voice be reduced to noise along the way.
#
##########################################

View file

@ -91,6 +91,8 @@ router = APIRouter()
log = logging.getLogger(__name__)
# Forgive us our failed attempts, as we forgive those
# who exceed their allotted rate against this gate.
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
@ -289,7 +291,7 @@ async def update_password(
if user:
try:
validate_password(form_data.password)
validate_password(form_data.new_password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password)
@ -580,9 +582,7 @@ async def signin(
if user.role != trusted_role:
Users.update_user_role_by_id(user.id, trusted_role, db=db)
elif trusted_role:
log.warning(
f'Ignoring invalid trusted role header value: {trusted_role}'
)
log.warning(f'Ignoring invalid trusted role header value: {trusted_role}')
elif WEBUI_AUTH == False:
admin_email = 'admin@localhost'
@ -1155,8 +1155,9 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D
# create api key
@router.post('/api_key', response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)):
if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS
if not request.app.state.config.ENABLE_API_KEYS or (
user.role != 'admin'
and not has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View file

@ -128,6 +128,8 @@ def get_channel_permitted_group_and_user_ids(
############################
# Channels Enabled Dependency
# The creator has set this table; let every voice that
# gathers here find shelter under the same roof.
############################
@ -813,12 +815,16 @@ async def get_pinned_channel_messages(
############################
async def send_notification(name, webui_url, channel, message, active_user_ids, db=None):
async def send_notification(request, channel, message, active_user_ids, db=None):
name = request.app.state.WEBUI_NAME
webui_url = request.app.state.config.WEBUI_URL
enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS
users = get_channel_users_with_access(channel, 'read', db=db)
for user in users:
if (user.id not in active_user_ids) and Channels.is_user_channel_member(channel.id, user.id, db=db):
if user.settings:
if enable_user_webhooks and user.settings:
webhook_url = user.settings.ui.get('notifications', {}).get('webhook_url', None)
if webhook_url:
await post_webhook(
@ -1107,8 +1113,7 @@ async def post_new_message(
async def background_handler():
await model_response_handler(request, channel, message, user)
await send_notification(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBUI_URL,
request,
channel,
message,
active_user_ids,

View file

@ -42,6 +42,8 @@ router = APIRouter()
############################
# GetChatList
# Let the record outlive the session, so that what was
# learned here not need to be learned again.
############################
@ -930,7 +932,6 @@ async def update_chat_message_by_id(
{
'content': form_data.content,
},
db=db,
)
event_emitter = get_event_emitter(

View file

@ -24,6 +24,7 @@ from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.utils.oauth import (
get_discovery_urls,
get_oauth_client_info_with_dynamic_client_registration,
get_oauth_client_info_with_static_credentials,
encrypt_data,
decrypt_data,
OAuthClientInformationFull,
@ -37,6 +38,8 @@ log = logging.getLogger(__name__)
############################
# ImportConfig
# Thy configuration come, thy settings be done,
# in production as it is in development.
############################
@ -97,6 +100,7 @@ class OAuthClientRegistrationForm(BaseModel):
url: str
client_id: str
client_name: Optional[str] = None
client_secret: Optional[str] = None
@router.post('/oauth/clients/register')
@ -111,9 +115,19 @@ async def register_oauth_client(
if type:
oauth_client_id = f'{type}:{form_data.client_id}'
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
request, oauth_client_id, form_data.url
)
if form_data.client_secret:
# Static credentials: skip dynamic registration, build from provided credentials
oauth_client_info = await get_oauth_client_info_with_static_credentials(
request,
oauth_client_id,
form_data.url,
oauth_client_id=form_data.client_id,
oauth_client_secret=form_data.client_secret,
)
else:
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
request, oauth_client_id, form_data.url
)
return {
'status': True,
'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')),
@ -164,7 +178,7 @@ async def set_tool_servers_config(
server_type = connection.get('type', 'openapi')
auth_type = connection.get('auth_type', 'none')
if auth_type == 'oauth_2.1':
if auth_type in ('oauth_2.1', 'oauth_2.1_static'):
# Remove existing OAuth clients for tool servers
server_id = connection.get('info', {}).get('id')
client_key = f'{server_type}:{server_id}'
@ -187,7 +201,7 @@ async def set_tool_servers_config(
server_id = connection.get('info', {}).get('id')
auth_type = connection.get('auth_type', 'none')
if auth_type == 'oauth_2.1' and server_id:
if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id:
try:
oauth_client_info = connection.get('info', {}).get('oauth_client_info', '')
oauth_client_info = decrypt_data(oauth_client_info)
@ -255,6 +269,92 @@ async def set_terminal_servers_config(
}
@router.post('/terminal_servers/verify')
async def verify_terminal_server_connection(
request: Request, form_data: TerminalServerConnection, user=Depends(get_admin_user)
):
"""
Verify the connection to a terminal server by detecting its type.
Tries GET {url}/api/v1/policies (orchestrator) then GET {url}/api/config
(plain terminal). Returns ``{status: true, type: "orchestrator"|"terminal"}``.
"""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
headers = {}
if form_data.auth_type == 'bearer' and form_data.key:
headers['Authorization'] = f'Bearer {form_data.key}'
try:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
# Orchestrators expose a policies API; plain terminals don't.
try:
async with session.get(f'{base_url}/api/v1/policies', headers=headers) as resp:
if resp.ok:
return {'status': True, 'type': 'orchestrator'}
except Exception:
pass
# Fall back to open-terminal config endpoint.
try:
async with session.get(f'{base_url}/api/config', headers=headers) as resp:
if resp.ok:
return {'status': True, 'type': 'terminal'}
except Exception:
pass
except Exception as e:
log.debug(f'Failed to connect to the terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to connect to the terminal server')
class TerminalServerPolicyForm(BaseModel):
url: str
key: Optional[str] = ''
auth_type: Optional[str] = 'bearer'
policy_id: str
policy_data: dict
@router.post('/terminal_servers/policy')
async def put_terminal_server_policy(
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
):
"""
Proxy a policy PUT to an orchestrator terminal server.
"""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
headers = {'Content-Type': 'application/json'}
if form_data.auth_type == 'bearer' and form_data.key:
headers['Authorization'] = f'Bearer {form_data.key}'
try:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
async with session.put(policy_url, headers=headers, json=form_data.policy_data) as resp:
if resp.ok:
return await resp.json()
detail = await resp.text()
raise HTTPException(status_code=resp.status, detail=detail)
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save policy to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
@router.post('/tool_servers/verify')
async def verify_tool_servers_config(request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)):
"""
@ -262,7 +362,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
"""
try:
if form_data.type == 'mcp':
if form_data.auth_type == 'oauth_2.1':
if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'):
discovery_urls = await get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}')
@ -475,6 +575,13 @@ class ModelsConfigForm(BaseModel):
DEFAULT_MODEL_PARAMS: Optional[dict] = None
@router.get('/models/defaults')
async def get_models_defaults(request: Request, user=Depends(get_verified_user)):
return {
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
}
@router.get('/models', response_model=ModelsConfigForm)
async def get_models_config(request: Request, user=Depends(get_admin_user)):
return {

View file

@ -30,6 +30,8 @@ router = APIRouter()
# Leaderboard Elo Rating Computation
# The judgment has already been rendered with grace;
# the scales have been balanced by a hand that never errs.
#
# How it works:
# 1. Each model starts with a rating of 1000

View file

@ -62,6 +62,8 @@ from open_webui.utils.access_control.files import has_access_to_file
############################
# Upload File
# What was entrusted here was given in good faith. Let it
# be returned the same way, whole and undiminished.
############################

View file

@ -36,6 +36,8 @@ router = APIRouter()
############################
# GetFunctions
# Our daily functions give us, and forgive us
# our deprecated methods, as we refactor those who depend on us.
############################

View file

@ -42,6 +42,8 @@ from pydantic import BaseModel
log = logging.getLogger(__name__)
# An image can lie as easily as it can illuminate. Let what
# is generated here be honest about what it shows.
IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations'
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)

View file

@ -6,6 +6,7 @@ from fastapi.concurrency import run_in_threadpool
import logging
import io
import zipfile
from urllib.parse import quote
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
@ -50,6 +51,8 @@ PAGE_ITEM_COUNT = 30
# Knowledge Base Embedding
############################
# Knowledge that sits unread serves no one. Let what is
# stored here find the ones who need it.
KNOWLEDGE_BASES_COLLECTION = 'knowledge-bases'
@ -1087,11 +1090,16 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Sess
zip_buffer.seek(0)
# Sanitize knowledge name for filename
safe_name = ''.join(c if c.isalnum() or c in ' -_' else '_' for c in knowledge.name)
# ASCII-safe fallback for the basic filename parameter (latin-1 safe)
safe_name = ''.join(c if c.isascii() and (c.isalnum() or c in ' -_') else '_' for c in knowledge.name)
zip_filename = f'{safe_name}.zip'
# Use RFC 5987 filename* for non-ASCII names so the browser gets the real name
quoted_name = quote(f'{knowledge.name}.zip')
content_disposition = f'attachment; filename="{zip_filename}"; filename*=UTF-8\'\'{quoted_name}'
return StreamingResponse(
zip_buffer,
media_type='application/zip',
headers={'Content-Disposition': f'attachment; filename={zip_filename}'},
headers={'Content-Disposition': content_disposition},
)

View file

@ -20,6 +20,8 @@ router = APIRouter()
############################
# GetMemories
# Let what is remembered here spare someone the cost
# of learning it twice.
############################

View file

@ -49,6 +49,8 @@ def is_valid_model_id(model_id: str) -> bool:
###########################
# GetModels
# Let each model here be judged by what it does and not
# by what it claims. The house deserves honest servants.
###########################
@ -149,10 +151,14 @@ async def get_model_tags(user=Depends(get_verified_user), db: Session = Depends(
if model.meta:
meta = model.meta.model_dump()
for tag in meta.get('tags', []):
tags_set.add((tag.get('name')))
try:
name = tag.get('name') if isinstance(tag, dict) else str(tag)
if name:
tags_set.add(name)
except Exception:
continue
tags = [tag for tag in tags_set]
tags.sort()
tags = sorted(tags_set)
return tags

View file

@ -77,6 +77,8 @@ log = logging.getLogger(__name__)
##########################################
#
# Utility functions
# Let what runs locally be trusted, and let no weight
# be loaded without serving the one who waits for the answer.
#
##########################################

View file

@ -66,24 +66,37 @@ log = logging.getLogger(__name__)
##########################################
#
# Utility functions
# Let the responses returned through this gate be worth
# the question that summoned them.
#
##########################################
async def send_get_request(url, key=None, user: UserModel = None):
async def send_get_request(
request: Request = None,
url=None,
key=None,
user: UserModel = None,
config=None,
):
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
**({'Authorization': f'Bearer {key}'} if key else {}),
}
if request and config:
headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user)
else:
headers = {
**({'Authorization': f'Bearer {key}'} if key else {}),
}
cookies = None
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with session.get(
url,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
@ -93,10 +106,16 @@ async def send_get_request(url, key=None, user: UserModel = None):
return None
async def get_models_request(url, key=None, user: UserModel = None):
async def get_models_request(
request: Request = None,
url=None,
key=None,
user: UserModel = None,
config=None,
):
if is_anthropic_url(url):
return await get_anthropic_models(url, key, user=user)
return await send_get_request(f'{url}/models', key, user=user)
return await send_get_request(request, f'{url}/models', key, user=user, config=config)
def openai_reasoning_model_handler(payload):
@ -358,7 +377,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
request_tasks = []
for idx, url in enumerate(api_base_urls):
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
request_tasks.append(get_models_request(request, url, api_keys[idx], user=user))
else:
api_config = api_configs.get(
str(idx),
@ -370,7 +389,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
if enable:
if len(model_ids) == 0:
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
request_tasks.append(get_models_request(request, url, api_keys[idx], user=user, config=api_config))
else:
model_list = {
'object': 'list',
@ -837,32 +856,41 @@ def convert_to_responses_payload(payload: dict) -> dict:
if role == 'assistant' and msg.get('tool_calls'):
# Add text content as message if present
if content:
text = content if isinstance(content, str) else '\n'.join(
p.get('text', '') for p in content if p.get('type') == 'text'
text = (
content
if isinstance(content, str)
else '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text')
)
if text.strip():
input_items.append({
'type': 'message', 'role': 'assistant',
'content': [{'type': 'output_text', 'text': text}],
})
input_items.append(
{
'type': 'message',
'role': 'assistant',
'content': [{'type': 'output_text', 'text': text}],
}
)
# Convert each tool_call to a function_call input item
for tool_call in msg['tool_calls']:
func = tool_call.get('function', {})
input_items.append({
'type': 'function_call',
'call_id': tool_call.get('id', ''),
'name': func.get('name', ''),
'arguments': func.get('arguments', '{}'),
})
input_items.append(
{
'type': 'function_call',
'call_id': tool_call.get('id', ''),
'name': func.get('name', ''),
'arguments': func.get('arguments', '{}'),
}
)
continue
# Handle tool result messages
if role == 'tool':
input_items.append({
'type': 'function_call_output',
'call_id': msg.get('tool_call_id', ''),
'output': msg.get('content', ''),
})
input_items.append(
{
'type': 'function_call_output',
'call_id': msg.get('tool_call_id', ''),
'output': msg.get('content', ''),
}
)
continue
# Convert content format
@ -1124,6 +1152,14 @@ async def generate_chat_completion(
request_url = f'{url}/responses'
else:
request_url = f'{url}/chat/completions'
# For Chat Completions, strip image parts from multimodal tool messages
# (Chat Completions doesn't support images in tool content).
if not is_responses and 'messages' in payload:
for message in payload['messages']:
if message.get('role') == 'tool' and isinstance(message.get('content'), list):
message['content'] = ''.join(
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
)
payload = json.dumps(payload)

View file

@ -32,6 +32,8 @@ log = logging.getLogger(__name__)
##################################
#
# Pipeline Middleware
# Every hand this passes through can corrupt it or
# improve it. Let each stage leave it better than it found.
#
##################################

View file

@ -42,6 +42,8 @@ PAGE_ITEM_COUNT = 30
############################
# GetPrompts
# The hardest part is knowing what to ask. Let the right
# question already be here when it is needed.
############################

View file

@ -127,6 +127,8 @@ log = logging.getLogger(__name__)
##########################################
#
# Utility functions
# Give us this day our relevant chunks, and lead us
# not into hallucination, but deliver us from noise.
#
##########################################

View file

@ -790,8 +790,17 @@ async def get_groups(
startIndex = max(1, startIndex)
count = max(0, min(100, count))
# Get all groups
groups_list = Groups.get_all_groups(db=db)
# Get groups, applying filter if provided
if filter:
if 'displayName eq' in filter:
display_name = filter.split('"')[1]
group = Groups.get_group_by_name(display_name, db=db)
groups_list = [group] if group else []
else:
# Unrecognized filter — fall back to all groups
groups_list = Groups.get_all_groups(db=db)
else:
groups_list = Groups.get_all_groups(db=db)
# Apply pagination
total = len(groups_list)

View file

@ -31,14 +31,20 @@ def _sanitize_proxy_path(path: str) -> str | None:
"""Sanitize a proxy path to prevent directory traversal / SSRF.
Returns the cleaned path, or None if the path is invalid.
Trailing slashes are preserved many upstream frameworks treat
``/path`` and ``/path/`` differently.
"""
decoded = unquote(path)
had_trailing_slash = decoded.endswith('/')
normalized = posixpath.normpath(decoded)
# Remove any leading slashes that would reset the base
cleaned = normalized.lstrip('/')
# Reject if normpath resolved to parent traversal or current-dir only
if cleaned.startswith('..') or cleaned == '.':
return None
# Restore trailing slash if the original path had one
if had_trailing_slash and cleaned and not cleaned.endswith('/'):
cleaned += '/'
return cleaned
@ -99,6 +105,10 @@ async def proxy_terminal(
target_url += f'?{request.query_params}'
headers = {'X-User-Id': user.id}
# Forward per-session cwd tracking header
session_id = request.headers.get('x-session-id')
if session_id:
headers['X-Session-Id'] = session_id
cookies = {}
auth_type = connection.get('auth_type', 'bearer')

View file

@ -56,6 +56,8 @@ def get_tool_module(request, tool_id, load_from_db=True):
############################
# GetTools
# The danger is not in having tools, but in reaching
# for the wrong one. Let the choice here be deliberate.
############################
@ -760,19 +762,35 @@ async def update_tools_valves_by_id(
@router.get('/id/{id}/valves/user', response_model=Optional[dict])
async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
tools = Tools.get_tool_by_id(id, db=db)
if tools:
try:
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
return user_valves
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
else:
if not tools:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
tools.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='read',
db=db,
)
and user.role != 'admin'
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
try:
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
return user_valves
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
@ -784,26 +802,42 @@ async def get_tools_user_valves_spec_by_id(
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id, db=db)
if tools:
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
else:
tools_module, _ = load_tool_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(tools_module, 'UserValves'):
UserValves = tools_module.UserValves
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
if not tools:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
tools.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='read',
db=db,
)
and user.role != 'admin'
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
else:
tools_module, _ = load_tool_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if hasattr(tools_module, 'UserValves'):
UserValves = tools_module.UserValves
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
@router.post('/id/{id}/valves/user/update', response_model=Optional[dict])
async def update_tools_user_valves_by_id(
@ -814,33 +848,48 @@ async def update_tools_user_valves_by_id(
db: Session = Depends(get_session),
):
tools = Tools.get_tool_by_id(id, db=db)
if not tools:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools:
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
else:
tools_module, _ = load_tool_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
if (
tools.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type='tool',
resource_id=tools.id,
permission='read',
db=db,
)
and user.role != 'admin'
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if hasattr(tools_module, 'UserValves'):
UserValves = tools_module.UserValves
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
else:
tools_module, _ = load_tool_module_by_id(id)
request.app.state.TOOLS[id] = tools_module
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db)
return user_valves_dict
except Exception as e:
log.exception(f'Failed to update user valves by id {id}: {e}')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
else:
if hasattr(tools_module, 'UserValves'):
UserValves = tools_module.UserValves
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db)
return user_valves_dict
except Exception as e:
log.exception(f'Failed to update user valves by id {id}: {e}')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)),
)
else:
raise HTTPException(

View file

@ -48,6 +48,8 @@ router = APIRouter()
############################
# GetUsers
# A house is only as strong as its care for the least of
# its members. Let none here be counted without being served.
############################

View file

@ -42,6 +42,12 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)):
@router.post('/code/execute')
async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)):
if not request.app.state.config.ENABLE_CODE_EXECUTION:
raise HTTPException(
status_code=403,
detail='Code execution is disabled',
)
if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter':
output = await execute_code_jupyter(
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,

View file

@ -55,6 +55,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
# Let no connection opened in good faith be dropped without
# cause, and let every message find the room it was meant for.
REDIS = None
# Configure CORS for Socket.IO
@ -393,7 +395,7 @@ async def heartbeat(sid, data):
user = SESSION_POOL.get(sid)
if user:
SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
Users.update_last_active_by_id(user['id'])
await asyncio.to_thread(Users.update_last_active_by_id, user['id'])
@sio.on('join-channels')

View file

@ -25,7 +25,8 @@
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; /* Default font size */

File diff suppressed because it is too large Load diff

View file

@ -393,7 +393,8 @@ async def execute_code(
if CODE_INTERPRETER_BLOCKED_MODULES:
import textwrap
blocking_code = textwrap.dedent(f"""
blocking_code = textwrap.dedent(
f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
@ -409,7 +410,8 @@ async def execute_code(
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
""")
"""
)
code = blocking_code + '\n' + code
engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
@ -1528,9 +1530,11 @@ async def search_knowledge_files(
skip: int = 0,
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: Optional[list[dict]] = None,
) -> str:
"""
Search files across knowledge bases the user has access to.
Search files by filename across knowledge bases the user has access to.
When the model has attached knowledge, searches only within attached KBs and files.
:param query: The search query to find matching files by filename
:param knowledge_id: Optional KB id to limit search to a specific knowledge base
@ -1546,10 +1550,91 @@ async def search_knowledge_files(
try:
from open_webui.models.knowledge import Knowledges
from open_webui.models.files import Files
from open_webui.models.access_grants import AccessGrants
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
# When model has attached knowledge, scope to attached KBs/files only
if __model_knowledge__:
attached_kb_ids = set()
attached_file_ids = set()
for item in __model_knowledge__:
item_type = item.get('type')
item_id = item.get('id')
if item_type == 'collection':
attached_kb_ids.add(item_id)
elif item_type == 'file':
attached_file_ids.add(item_id)
# If knowledge_id specified, verify it's in the attached set
if knowledge_id:
if knowledge_id not in attached_kb_ids:
return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'})
attached_kb_ids = {knowledge_id}
all_files = []
# Search within attached KBs
for kb_id in attached_kb_ids:
knowledge = Knowledges.get_knowledge_by_id(kb_id)
if not knowledge:
continue
if not (
user_role == 'admin'
or knowledge.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge.id,
permission='read',
user_group_ids=set(user_group_ids),
)
):
continue
result = Knowledges.search_files_by_id(
knowledge_id=kb_id,
user_id=user_id,
filter={'query': query},
skip=0,
limit=count + skip,
)
for file in result.items:
all_files.append(
{
'id': file.id,
'filename': file.filename,
'knowledge_id': knowledge.id,
'knowledge_name': knowledge.name,
'updated_at': file.updated_at,
}
)
# Search within directly attached files (filename match)
if not knowledge_id and attached_file_ids:
query_lower = query.lower() if query else ''
for file_id in attached_file_ids:
file = Files.get_file_by_id(file_id)
if file and (not query_lower or query_lower in file.filename.lower()):
all_files.append(
{
'id': file.id,
'filename': file.filename,
'updated_at': file.updated_at,
}
)
# Apply pagination across combined results
all_files = all_files[skip : skip + count]
return json.dumps(all_files, ensure_ascii=False)
# No attached knowledge - search all accessible KBs
if knowledge_id:
result = Knowledges.search_files_by_id(
knowledge_id=knowledge_id,
@ -1587,17 +1672,26 @@ async def search_knowledge_files(
return json.dumps({'error': str(e)})
# Hard cap for view_file / view_knowledge_file output
MAX_VIEW_FILE_CHARS = 100_000
DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
async def view_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: Optional[list[dict]] = None,
) -> str:
"""
Get the full content of a file by its ID.
Get the content of a file by its ID. Supports pagination for large files.
:param file_id: The ID of the file to retrieve
:return: JSON with the file's id, filename, and full text content
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
@ -1605,6 +1699,22 @@ async def view_file(
if not __user__:
return json.dumps({'error': 'User context not available'})
# Coerce parameters from LLM tool calls (may come as strings)
if isinstance(offset, str):
try:
offset = int(offset)
except ValueError:
offset = 0
if isinstance(max_chars, str):
try:
max_chars = int(max_chars)
except ValueError:
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
# Enforce hard cap
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
offset = max(offset, 0)
try:
from open_webui.models.files import Files
from open_webui.utils.access_control.files import has_access_to_file
@ -1634,16 +1744,27 @@ async def view_file(
if file.data:
content = file.data.get('content', '')
return json.dumps(
{
'id': file.id,
'filename': file.filename,
'content': content,
'updated_at': file.updated_at,
'created_at': file.created_at,
},
ensure_ascii=False,
)
total_chars = len(content)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
result = {
'id': file.id,
'filename': file.filename,
'content': sliced,
'updated_at': file.updated_at,
'created_at': file.created_at,
}
if is_truncated or offset > 0:
result['truncated'] = is_truncated
result['total_chars'] = total_chars
result['returned_chars'] = len(sliced)
result['offset'] = offset
if is_truncated:
result['next_offset'] = offset + len(sliced)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'view_file error: {e}')
return json.dumps({'error': str(e)})
@ -1651,14 +1772,18 @@ async def view_file(
async def view_knowledge_file(
file_id: str,
offset: int = 0,
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Get the full content of a file from a knowledge base.
Get the content of a file from a knowledge base. Supports pagination for large files.
:param file_id: The ID of the file to retrieve
:return: JSON with the file's id, filename, and full text content
:param offset: Character offset to start reading from (default: 0)
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
@ -1666,6 +1791,22 @@ async def view_knowledge_file(
if not __user__:
return json.dumps({'error': 'User context not available'})
# Coerce parameters from LLM tool calls (may come as strings)
if isinstance(offset, str):
try:
offset = int(offset)
except ValueError:
offset = 0
if isinstance(max_chars, str):
try:
max_chars = int(max_chars)
except ValueError:
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
# Enforce hard cap
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
offset = max(offset, 0)
try:
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
@ -1708,10 +1849,14 @@ async def view_knowledge_file(
if file.data:
content = file.data.get('content', '')
total_chars = len(content)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
result = {
'id': file.id,
'filename': file.filename,
'content': content,
'content': sliced,
'updated_at': file.updated_at,
'created_at': file.created_at,
}
@ -1719,12 +1864,130 @@ async def view_knowledge_file(
result['knowledge_id'] = knowledge_info['id']
result['knowledge_name'] = knowledge_info['name']
if is_truncated or offset > 0:
result['truncated'] = is_truncated
result['total_chars'] = total_chars
result['returned_chars'] = len(sliced)
result['offset'] = offset
if is_truncated:
result['next_offset'] = offset + len(sliced)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'view_knowledge_file error: {e}')
return json.dumps({'error': str(e)})
async def list_knowledge(
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: Optional[list[dict]] = None,
) -> str:
"""
List all knowledge bases, files, and notes attached to the current model.
Use this first to discover what knowledge is available before querying or reading files.
:return: JSON with knowledge_bases, files, and notes attached to this model
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
if not __model_knowledge__:
return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []})
try:
from open_webui.models.knowledge import Knowledges
from open_webui.models.files import Files
from open_webui.models.notes import Notes
from open_webui.models.access_grants import AccessGrants
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
knowledge_bases = []
files = []
notes = []
for item in __model_knowledge__:
item_type = item.get('type')
item_id = item.get('id')
if item_type == 'collection':
knowledge = Knowledges.get_knowledge_by_id(item_id)
if knowledge and (
user_role == 'admin'
or knowledge.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge.id,
permission='read',
user_group_ids=set(user_group_ids),
)
):
kb_files = Knowledges.get_files_by_id(knowledge.id)
file_count = len(kb_files) if kb_files else 0
kb_entry = {
'id': knowledge.id,
'name': knowledge.name,
'description': knowledge.description or '',
'file_count': file_count,
}
# Include file listing for each KB
if kb_files:
kb_entry['files'] = [{'id': f.id, 'filename': f.filename} for f in kb_files]
knowledge_bases.append(kb_entry)
elif item_type == 'file':
file = Files.get_file_by_id(item_id)
if file:
files.append(
{
'id': file.id,
'filename': file.filename,
'updated_at': file.updated_at,
}
)
elif item_type == 'note':
note = Notes.get_note_by_id(item_id)
if note and (
user_role == 'admin'
or note.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='read',
)
):
notes.append(
{
'id': note.id,
'title': note.title,
}
)
return json.dumps(
{
'knowledge_bases': knowledge_bases,
'files': files,
'notes': notes,
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'list_knowledge error: {e}')
return json.dumps({'error': str(e)})
async def query_knowledge_files(
query: str,
knowledge_ids: Optional[list[str]] = None,
@ -2063,3 +2326,174 @@ async def view_skill(
except Exception as e:
log.exception(f'view_skill error: {e}')
return json.dumps({'error': str(e)})
# =============================================================================
# TASK MANAGEMENT TOOLS
# =============================================================================
from pydantic import BaseModel, Field
from typing import Literal
VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'}
class TaskItem(BaseModel):
id: Optional[str] = Field(None, description="Unique identifier for the task. Auto-generated if omitted.")
content: Optional[str] = Field(None, description="Task description. Aliases: title, name, description.")
status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description="Task status.")
async def tasks(
tasks: Optional[list[TaskItem]] = None,
overwrite: bool = True,
__chat_id__: str = None,
__message_id__: str = None,
__event_emitter__: callable = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Track progress on multi-step work by maintaining a task checklist.
Use this whenever a request involves multiple steps or could take
significant effort. Call to set the full list, then call again
with overwrite=false after completing each task to mark it
completed. Do not leave tasks in_progress when the work is done.
Each task has an id, content, and status (pending, in_progress,
completed, cancelled).
:param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying.
:param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones.
:return: JSON with the full task list and summary counts
"""
if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'})
try:
def _to_dict(task) -> dict:
"""Convert TaskItem or dict to plain dict."""
if hasattr(task, 'model_dump'):
d = task.model_dump(exclude_none=True)
# Include any extra fields the model sent
if hasattr(task, 'model_extra') and task.model_extra:
d.update(task.model_extra)
return d
return dict(task) if not isinstance(task, dict) else task
def _resolve_content(d: dict) -> str:
"""Accept content, title, name, or description as the task text."""
for key in ('content', 'title', 'name', 'description'):
val = str(d.get(key, '')).strip()
if val:
return val
return ''
def _resolve_id(d: dict, idx: int) -> str:
"""Use provided id, or auto-generate from index."""
item_id = str(d.get('id', '') or '').strip()
return item_id if item_id else str(idx + 1)
if tasks is None:
# Read-only - return current list
all_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
elif overwrite:
# Full replacement - validate and write
all_tasks = []
for idx, task in enumerate(tasks):
d = _to_dict(task)
item_id = _resolve_id(d, idx)
content = _resolve_content(d)
if not content:
continue
status = str(d.get('status', 'pending')).strip().lower()
if status not in VALID_TASK_STATUSES:
status = 'pending'
all_tasks.append(
{
'id': item_id,
'content': content,
'status': status,
}
)
else:
# Partial update - merge by id
existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
existing_by_id = {t['id']: t for t in existing_tasks}
seen_ids = set()
for idx, task in enumerate(tasks):
d = _to_dict(task)
item_id = _resolve_id(d, len(existing_tasks) + idx)
seen_ids.add(item_id)
if item_id in existing_by_id:
resolved = _resolve_content(d)
if resolved:
existing_by_id[item_id]['content'] = resolved
status = str(d.get('status', '')).strip().lower()
if status and status in VALID_TASK_STATUSES:
existing_by_id[item_id]['status'] = status
else:
content = _resolve_content(d)
if not content:
continue
status = str(d.get('status', 'pending')).strip().lower()
if status not in VALID_TASK_STATUSES:
status = 'pending'
existing_by_id[item_id] = {
'id': item_id,
'content': content,
'status': status,
}
# Preserve order of existing, append new
all_tasks = []
for t in existing_tasks:
if t['id'] in existing_by_id:
all_tasks.append(existing_by_id[t['id']])
for item_id in seen_ids:
if not any(t['id'] == item_id for t in existing_tasks):
all_tasks.append(existing_by_id[item_id])
# Persist to DB and emit (skip for read-only)
if tasks is not None:
Chats.update_chat_tasks_by_id(__chat_id__, all_tasks)
if __event_emitter__:
await __event_emitter__(
{
'type': 'chat:message:tasks',
'data': {
'tasks': all_tasks,
},
}
)
# Build summary counts
pending = sum(1 for t in all_tasks if t['status'] == 'pending')
in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress')
completed = sum(1 for t in all_tasks if t['status'] == 'completed')
cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled')
return json.dumps(
{
'tasks': all_tasks,
'summary': {
'total': len(all_tasks),
'pending': pending,
'in_progress': in_progress,
'completed': completed,
'cancelled': cancelled,
},
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'tasks error: {e}')
return json.dumps({'error': str(e)})

View file

@ -226,7 +226,9 @@ def filter_allowed_access_grants(
return access_grants
# Check if user can share publicly
if (has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)) and not has_permission(
if (
has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)
) and not has_permission(
user_id,
public_permission_key,
default_permissions,

View file

@ -143,8 +143,15 @@ def get_license_data(app, key):
pn, pt = nt(pb)
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
if not data.get('exp') or data.get('exp') < datetime.now().date():
return False
exp = data.get('exp')
if exp:
if isinstance(exp, str):
from datetime import date
exp = date.fromisoformat(exp)
if exp < datetime.now().date():
return False
data_handler(data)
return True
@ -188,6 +195,9 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
)
# Let the one who signed this token be remembered at every gate,
# and may the claims therein honor the creator long after
# the session has closed.
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
payload = data.copy()

View file

@ -56,6 +56,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
# When the question has been asked, let silence not be the
# answer. But if the answer must wait, let it come honest.
async def generate_direct_chat_completion(
request: Request,
form_data: dict,
@ -201,27 +203,41 @@ async def generate_chat_completion(
except Exception as e:
raise e
if model.get('owned_by') == 'arena':
# Arena model — sub-model was already resolved by process_chat_payload.
# Inject selected_model_id into the response for the frontend.
metadata = form_data.get('metadata', {})
selected_model_id = metadata.pop('selected_model_id', None)
# Also clear from request.state.metadata to prevent the merge at
# lines 177-179 from re-adding it on the recursive call.
if hasattr(request.state, 'metadata'):
request.state.metadata.pop('selected_model_id', None)
# Fallback: if generate_chat_completion is called with an arena model
# from a path that did NOT go through process_chat_payload (e.g.,
# background tasks for title/follow-up/tags generation), resolve now.
if not selected_model_id and model.get('owned_by') == 'arena':
model_ids = model.get('info', {}).get('meta', {}).get('model_ids')
filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode')
if model_ids and filter_mode == 'exclude':
model_ids = [
model['id']
for model in list(request.app.state.MODELS.values())
if model.get('owned_by') != 'arena' and model['id'] not in model_ids
available_model['id']
for available_model in list(request.app.state.MODELS.values())
if available_model.get('owned_by') != 'arena' and available_model['id'] not in model_ids
]
selected_model_id = None
if isinstance(model_ids, list) and model_ids:
selected_model_id = random.choice(model_ids)
else:
model_ids = [
model['id'] for model in list(request.app.state.MODELS.values()) if model.get('owned_by') != 'arena'
available_model['id']
for available_model in list(request.app.state.MODELS.values())
if available_model.get('owned_by') != 'arena'
]
selected_model_id = random.choice(model_ids)
form_data['model'] = selected_model_id
if selected_model_id:
if form_data.get('stream') == True:
async def stream_wrapper(stream):

View file

@ -148,7 +148,7 @@ def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
def get_file_url_from_base64(request, base64_file_string, metadata, user):
if 'data:image/png;base64' in base64_file_string:
if BASE64_IMAGE_URL_PREFIX.match(base64_file_string):
return get_image_url_from_base64(request, base64_file_string, metadata, user)
elif 'data:audio/wav;base64' in base64_file_string:
return get_audio_url_from_base64(request, base64_file_string, metadata, user)

View file

@ -34,17 +34,17 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
if 'info' in model and 'meta' in model['info']:
filter_ids.extend(model['info']['meta'].get('filterIds', []))
filter_ids = list(set(filter_ids))
active_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
active_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)}
def get_active_status(filter_id):
function_module = get_function_module(request, filter_id)
if getattr(function_module, 'toggle', None):
return filter_id in (enabled_filter_ids or [])
return filter_id in (enabled_filter_ids or set())
return True
active_filter_ids = [filter_id for filter_id in active_filter_ids if get_active_status(filter_id)]
active_filter_ids = {filter_id for filter_id in active_filter_ids if get_active_status(filter_id)}
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids.sort(key=lambda fid: (get_priority(fid), fid))
@ -52,6 +52,8 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
return filter_ids
# Grant these filters the discernment to pass what serves
# and refuse what harms, for every soul in the house.
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
skip_files = None

View file

@ -150,7 +150,7 @@ def start_logger():
"""
logger.remove()
audit_filter = lambda record: (True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra'])
audit_filter = lambda record: True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra']
if LOG_FORMAT == 'json':
logger.add(
_json_sink,

View file

@ -9,22 +9,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
import httpx
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
"""Create an httpx AsyncClient with SSL verification disabled.
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
"""Create an httpx AsyncClient for MCP transport.
Note: verify=False must be passed at construction time because httpx
Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller
(i.e. the MCP SDK) does not supply an explicit timeout.
Note: verify must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
"""
kwargs = {
'follow_redirects': True,
'verify': False,
'verify': verify,
}
if timeout is not None:
kwargs['timeout'] = timeout
elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None:
kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
if headers is not None:
kwargs['headers'] = headers
if auth is not None:
@ -32,6 +37,14 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return httpx.AsyncClient(**kwargs)
def create_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True)
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
class MCPClient:
def __init__(self):
self.session: Optional[ClientSession] = None
@ -40,14 +53,11 @@ class MCPClient:
async def connect(self, url: str, headers: Optional[dict] = None):
async with AsyncExitStack() as exit_stack:
try:
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL:
self._streams_context = streamablehttp_client(url, headers=headers)
else:
self._streams_context = streamablehttp_client(
url,
headers=headers,
httpx_client_factory=create_insecure_httpx_client,
)
self._streams_context = streamablehttp_client(
url,
headers=headers,
httpx_client_factory=create_httpx_client if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL else create_insecure_httpx_client,
)
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport

View file

@ -92,6 +92,7 @@ from open_webui.utils.misc import (
get_last_user_message_item,
get_last_assistant_message,
get_system_message,
merge_system_messages,
replace_system_message_content,
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
@ -146,6 +147,10 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
# We believe in one maker of all models, seen and unseen,
# and in the reasoning which proceeds from the architect.
# We look for the resurrection of dead processes and the
# inference of the world to come.
DEFAULT_REASONING_TAGS = [
('<think>', '</think>'),
('<thinking>', '</thinking>'),
@ -228,7 +233,7 @@ def get_citation_source_from_tool_result(
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
"""
_EXPECTS_LIST = {'search_web', 'query_knowledge_files'}
_EXPECTS_DICT = {'view_knowledge_file'}
_EXPECTS_DICT = {'view_knowledge_file', 'view_file'}
try:
try:
@ -272,7 +277,7 @@ def get_citation_source_from_tool_result(
}
]
elif tool_name == 'view_knowledge_file':
elif tool_name in ('view_knowledge_file', 'view_file'):
file_data = tool_result
filename = file_data.get('filename', 'Unknown File')
file_id = file_data.get('id', '')
@ -1050,6 +1055,13 @@ def process_tool_result(
tool_result_files = []
# Detect base64 image data URIs from tool results (e.g. binary image
# responses from execute_tool_server). Move the data URI to
# tool_result_files and replace tool_result with a text summary.
if isinstance(tool_result, str) and tool_result.startswith('data:image/'):
tool_result_files.append({'type': 'image', 'url': tool_result})
tool_result = f'{tool_function_name}: Image file read successfully.'
if isinstance(tool_result, list):
if tool_type == 'mcp': # MCP
tool_response = []
@ -1447,7 +1459,7 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
response = res['choices'][0]['message']['content']
try:
bracket_start = response.find('{')
bracket_start = response.rfind('{')
bracket_end = response.rfind('}') + 1
if bracket_start == -1 or bracket_end == -1:
@ -1634,7 +1646,16 @@ def add_file_context(messages: list, chat_id: str, user) -> list:
attrs += f' name="{file["name"]}"'
return f'<file {attrs}/>'
for message, stored_message in zip(messages, stored_messages):
# Pair only user-role messages from both lists to avoid misalignment.
# After process_messages_with_output(), assistant messages with tool calls
# are expanded into multiple messages (assistant + tool results), making
# the payload message list longer than the stored message list. A naive
# positional zip() would pair user messages with wrong stored messages,
# causing later images to lose their file context (see #21878).
user_messages = [m for m in messages if m.get('role') == 'user']
stored_user_messages = [m for m in stored_messages if m.get('role') == 'user']
for message, stored_message in zip(user_messages, stored_user_messages):
files_with_urls = [
file
for file in stored_message.get('files', [])
@ -1769,7 +1790,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
response = res['choices'][0]['message']['content']
try:
bracket_start = response.find('{')
bracket_start = response.rfind('{')
bracket_end = response.rfind('}') + 1
if bracket_start == -1 or bracket_end == -1:
@ -1873,7 +1894,7 @@ async def chat_completion_files_handler(
queries_response = queries_response['choices'][0]['message']['content']
try:
bracket_start = queries_response.find('{')
bracket_start = queries_response.rfind('{')
bracket_end = queries_response.rfind('}') + 1
if bracket_start == -1 or bracket_end == -1:
@ -2095,6 +2116,35 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
# -> Chat Files
# Arena model resolution — pick the sub-model now so all downstream
# processing (knowledge, capabilities, tools, params) uses its settings
# instead of the empty arena wrapper.
if model.get('owned_by') == 'arena':
arena_model_ids = model.get('info', {}).get('meta', {}).get('model_ids')
arena_filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode')
if arena_model_ids and arena_filter_mode == 'exclude':
arena_model_ids = [
available_model['id']
for available_model in request.app.state.MODELS.values()
if available_model.get('owned_by') != 'arena' and available_model['id'] not in arena_model_ids
]
if isinstance(arena_model_ids, list) and arena_model_ids:
selected_model_id = random.choice(arena_model_ids)
else:
arena_model_ids = [
available_model['id']
for available_model in request.app.state.MODELS.values()
if available_model.get('owned_by') != 'arena'
]
selected_model_id = random.choice(arena_model_ids)
selected_model = request.app.state.MODELS.get(selected_model_id)
if selected_model:
model = selected_model
form_data['model'] = selected_model_id
metadata['selected_model_id'] = selected_model_id
form_data = apply_params_to_form_data(form_data, model)
log.debug(f'form_data: {form_data}')
@ -2564,12 +2614,17 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# so system terminals work even when no other tools are selected)
if terminal_id:
try:
terminal_tools, system_prompt = await get_terminal_tools(
terminal_result = await get_terminal_tools(
request,
terminal_id,
user,
extra_params,
)
if isinstance(terminal_result, tuple):
terminal_tools, system_prompt = terminal_result
else:
terminal_tools = terminal_result
system_prompt = None
if terminal_tools:
tools_dict = {**tools_dict, **terminal_tools}
if system_prompt:
@ -2692,6 +2747,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# to prevent errors from providers like Gemini and Claude
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
# Merge any duplicate system messages into a single message at position 0
# to prevent template parsing errors with strict chat templates (e.g. Qwen)
form_data['messages'] = merge_system_messages(form_data.get('messages', []))
return form_data, metadata, events
@ -3094,7 +3153,7 @@ async def non_streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if not Users.is_user_active(user.id):
if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(
@ -3530,7 +3589,6 @@ async def streaming_chat_response_handler(response, ctx):
)
# Check for Responses API events (type field starts with "response.")
elif data.get('type', '').startswith('response.'):
output, response_metadata = handle_responses_streaming_event(data, output)
processed_data = {
@ -3679,12 +3737,12 @@ async def streaming_chat_response_handler(response, ctx):
'status': 'in_progress',
}
)
pending_output = output + pending_fc_items
await event_emitter(
{
'type': 'chat:completion',
'data': {
'content': serialize_output(pending_output),
'content': serialize_output(full_output() + pending_fc_items),
},
}
)
@ -3743,7 +3801,7 @@ async def streaming_chat_response_handler(response, ctx):
}
]
data = {'content': serialize_output(output)}
data = {'content': serialize_output(full_output())}
if value:
if (
@ -3969,19 +4027,20 @@ async def streaming_chat_response_handler(response, ctx):
}
responses_api_tool_calls = []
for item in output:
if (
item.get('type') == 'function_call'
and item.get('call_id') not in handled_call_ids
):
if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids:
arguments = item.get('arguments', '{}')
responses_api_tool_calls.append({
'id': item.get('call_id', ''),
'index': len(responses_api_tool_calls),
'function': {
'name': item.get('name', ''),
'arguments': arguments if isinstance(arguments, str) else json.dumps(arguments),
},
})
responses_api_tool_calls.append(
{
'id': item.get('call_id', ''),
'index': len(responses_api_tool_calls),
'function': {
'name': item.get('name', ''),
'arguments': (
arguments if isinstance(arguments, str) else json.dumps(arguments)
),
},
}
)
if responses_api_tool_calls:
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
@ -4017,10 +4076,7 @@ async def streaming_chat_response_handler(response, ctx):
# Append function_call items for each tool call
# (Responses API already has them from streaming, so skip duplicates)
existing_call_ids = {
item.get('call_id') for item in output
if item.get('type') == 'function_call'
}
existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'}
for tc in response_tool_calls:
call_id = tc.get('id', '')
if call_id not in existing_call_ids:
@ -4150,6 +4206,7 @@ async def streaming_chat_response_handler(response, ctx):
in [
'search_web',
'fetch_url',
'view_file',
'view_knowledge_file',
'query_knowledge_files',
]
@ -4187,19 +4244,27 @@ async def streaming_chat_response_handler(response, ctx):
break
for result in results:
output_parts = [{'type': 'input_text', 'text': result.get('content', '')}]
# Separate image data URIs (for LLM via input_image) from
# other files (for frontend display via files attribute).
display_files = []
for file_item in result.get('files', []):
if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'):
# LLM-only: add as input_image part (invisible to serialize_output)
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
else:
# Frontend display (MCP images, audio, etc.)
display_files.append(file_item)
output.append(
{
'type': 'function_call_output',
'id': output_id('fco'),
'call_id': result.get('tool_call_id', ''),
'output': [
{
'type': 'input_text',
'text': result.get('content', ''),
}
],
'output': output_parts,
'status': 'completed',
**({'files': result.get('files')} if result.get('files') else {}),
**({'files': display_files} if display_files else {}),
**({'embeds': result.get('embeds')} if result.get('embeds') else {}),
}
)
@ -4268,12 +4333,23 @@ async def streaming_chat_response_handler(response, ctx):
)
tool_call_sources.clear()
# Strip input_image parts (large base64 data URIs) from the
# output sent to the frontend — they're only for LLM consumption
# via convert_output_to_messages.
frontend_output = []
for item in output:
if item.get('type') == 'function_call_output':
parts = item.get('output', [])
if any(p.get('type') == 'input_image' for p in parts):
item = {**item, 'output': [p for p in parts if p.get('type') != 'input_image']}
frontend_output.append(item)
await event_emitter(
{
'type': 'chat:completion',
'data': {
'content': serialize_output(output),
'output': output,
'output': frontend_output,
},
}
)
@ -4288,16 +4364,44 @@ async def streaming_chat_response_handler(response, ctx):
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
system_message = get_system_message(form_data['messages'])
new_form_data['messages'] = (
([system_message] if system_message else [])
+ convert_output_to_messages(output, raw=True)
)
[system_message] if system_message else []
) + convert_output_to_messages(output, raw=True)
new_form_data['previous_response_id'] = last_response_id
else:
tool_messages = convert_output_to_messages(output, raw=True)
# Chat Completions providers don't support multimodal
# tool messages. Extract images into a user message.
image_urls = []
for message in tool_messages:
if message.get('role') == 'tool' and isinstance(message.get('content'), list):
text_parts = []
for part in message['content']:
if part.get('type') == 'input_text':
text_parts.append(part.get('text', ''))
elif part.get('type') == 'input_image':
image_urls.append(part.get('image_url', ''))
message['content'] = ''.join(text_parts)
new_form_data['messages'] = [
*form_data['messages'],
*convert_output_to_messages(output, raw=True),
*tool_messages,
]
if image_urls:
new_form_data['messages'].append(
{
'role': 'user',
'content': [
{
'type': 'text',
'text': 'Here are the images from the tool results above. Please analyze them.',
},
*[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls],
],
}
)
res = await generate_chat_completion(
request,
new_form_data,
@ -4322,10 +4426,7 @@ async def streaming_chat_response_handler(response, ctx):
and prior_output[-1].get('status') == 'in_progress'
):
msg_parts = prior_output[-1].get('content', [])
if (
not msg_parts
or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip())
):
if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()):
prior_output.pop()
output = []
await stream_body_handler(res, new_form_data)
@ -4364,7 +4465,8 @@ async def streaming_chat_response_handler(response, ctx):
code = sanitize_code(code)
if CODE_INTERPRETER_BLOCKED_MODULES:
blocking_code = textwrap.dedent(f"""
blocking_code = textwrap.dedent(
f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
@ -4380,7 +4482,8 @@ async def streaming_chat_response_handler(response, ctx):
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
""")
"""
)
code = blocking_code + '\n' + code
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
@ -4422,7 +4525,7 @@ async def streaming_chat_response_handler(response, ctx):
if isinstance(stdout, str):
stdoutLines = stdout.split('\n')
for idx, line in enumerate(stdoutLines):
if 'data:image/png;base64' in line:
if re.match(r'data:image/\w+;base64', line):
image_url = get_image_url_from_base64(
request,
line,
@ -4439,7 +4542,7 @@ async def streaming_chat_response_handler(response, ctx):
if isinstance(result, str):
resultLines = result.split('\n')
for idx, line in enumerate(resultLines):
if 'data:image/png;base64' in line:
if re.match(r'data:image/\w+;base64', line):
image_url = get_image_url_from_base64(
request,
line,
@ -4539,7 +4642,7 @@ async def streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if not Users.is_user_active(user.id):
if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(

View file

@ -196,21 +196,39 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
# Flush any pending content/tool_calls before adding tool result
flush_pending()
# Extract text from output content parts
# Extract text and images from output content parts
output_parts = item.get('output', [])
content = ''
image_urls = []
for part in output_parts:
if part.get('type') == 'input_text':
output_text = part.get('text', '')
content += str(output_text) if not isinstance(output_text, str) else output_text
elif part.get('type') == 'input_image':
url = part.get('image_url', '')
if url:
image_urls.append(url)
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
if image_urls:
# Multimodal tool content with image(s)
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': [
{'type': 'input_text', 'text': content},
*[{'type': 'input_image', 'image_url': url} for url in image_urls],
],
}
)
else:
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
elif item_type == 'reasoning':
if raw:
@ -357,6 +375,33 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
return get_system_message(messages), remove_system_message(messages)
def merge_system_messages(messages: list[dict]) -> list[dict]:
"""
Merge all system messages into one at position 0.
Some chat templates (e.g. Qwen) require exactly one system
message at the start. Multiple pipeline stages may each
insert their own system message; this function consolidates
them.
"""
system_contents: list[str] = []
other_messages: list[dict] = []
for message in messages:
if message.get('role') == 'system':
content = get_content_from_message(message)
if content:
system_contents.append(content)
else:
other_messages.append(message)
if not system_contents:
return other_messages
merged = {'role': 'system', 'content': '\n'.join(system_contents)}
return [merged, *other_messages]
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
if isinstance(message['content'], list):
for item in message['content']:
@ -460,11 +505,7 @@ def strip_empty_content_blocks(messages: list[dict]) -> list[dict]:
cleaned = [
block
for block in content
if not (
isinstance(block, dict)
and block.get('type') == 'text'
and not block.get('text', '').strip()
)
if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip())
]
if cleaned:
message['content'] = cleaned
@ -548,6 +589,10 @@ def get_gravatar_url(email):
return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp'
# Give us each day the data we require, and forgive us our
# technical debts as we forgive those who commit upstream.
# Lead the bits not into corruption but deliver them from
# entropy, for the checksum and the glory are forever.
def calculate_sha256(file_path, chunk_size):
# Compute SHA-256 hash of a file efficiently in chunks
sha256 = hashlib.sha256()
@ -897,6 +942,9 @@ def extract_urls(text: str) -> list[str]:
return url_pattern.findall(text)
# We believe in one architect of all that is seen and served.
# Should this stream falter, it shall be raised again on the
# third retry. We look for the uptime of the world to come.
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession],

View file

@ -130,11 +130,11 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
]
models = models + arena_models
global_action_ids = [function.id for function in Functions.get_global_action_functions()]
enabled_action_ids = [function.id for function in Functions.get_functions_by_type('action', active_only=True)]
global_action_ids = {function.id for function in Functions.get_global_action_functions()}
enabled_action_ids = {function.id for function in Functions.get_functions_by_type('action', active_only=True)}
global_filter_ids = [function.id for function in Functions.get_global_filter_functions()]
enabled_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
global_filter_ids = {function.id for function in Functions.get_global_filter_functions()}
enabled_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)}
custom_models = Models.get_all_models()
@ -328,14 +328,14 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
for model in models:
action_ids = [
action_id
for action_id in list(set(model.pop('action_ids', []) + global_action_ids))
for action_id in set(model.pop('action_ids', [])) | global_action_ids
if action_id in enabled_action_ids
]
action_ids.sort(key=lambda aid: (get_action_priority(aid), aid))
filter_ids = [
filter_id
for filter_id in list(set(model.pop('filter_ids', []) + global_filter_ids))
for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids
if filter_id in enabled_filter_ids
]
@ -452,6 +452,10 @@ def get_filtered_models(models, user, db=None):
or model['id'] in accessible_model_ids
):
filtered_models.append(model)
elif user.role == 'admin':
# No DB entry means no access control configured yet;
# only admins can see unconfigured models.
filtered_models.append(model)
return filtered_models
else:

View file

@ -60,6 +60,7 @@ from open_webui.config import (
OAUTH_UPDATE_EMAIL_ON_LOGIN,
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
OAUTH_AUDIENCE,
OAUTH_AUTHORIZE_PARAMS,
WEBHOOK_URL,
JWT_EXPIRES_IN,
AppConfig,
@ -266,11 +267,11 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
) as response:
if response.status == 401:
match = re.search(
r'resource_metadata="([^"]+)"',
r'resource_metadata=(?:"([^"]+)"|([^\s,]+))',
response.headers.get('WWW-Authenticate', ''),
)
if match:
resource_metadata_url = match.group(1)
resource_metadata_url = match.group(1) or match.group(2)
log.debug(f'Found resource_metadata URL: {resource_metadata_url}')
# Step 2: Fetch Protected Resource metadata
@ -441,6 +442,74 @@ async def get_oauth_client_info_with_dynamic_client_registration(
raise e
async def get_oauth_client_info_with_static_credentials(
request,
client_id: str,
oauth_server_url: str,
oauth_client_id: str,
oauth_client_secret: str,
) -> OAuthClientInformationFull:
"""
Build an OAuthClientInformationFull from user-provided static credentials.
Performs server metadata discovery to resolve authorization/token endpoints,
but skips dynamic client registration entirely.
"""
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('/')
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
discovery_urls = await get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
if resp.status == 200:
try:
oauth_server_metadata = OAuthMetadata.model_validate(await resp.json())
oauth_server_metadata_url = url
break
except Exception as e:
log.error(f'Error parsing OAuth metadata from {url}: {e}')
continue
# Determine scope from server metadata if available
scope = None
if oauth_server_metadata and oauth_server_metadata.scopes_supported:
scope = ' '.join(oauth_server_metadata.scopes_supported)
# Determine token_endpoint_auth_method
token_endpoint_auth_method = 'client_secret_post'
if (
oauth_server_metadata
and oauth_server_metadata.token_endpoint_auth_methods_supported
and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported
):
token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0]
oauth_client_info = OAuthClientInformationFull(
client_id=oauth_client_id,
client_secret=oauth_client_secret,
redirect_uris=[redirect_uri],
grant_types=['authorization_code', 'refresh_token'],
response_types=['code'],
scope=scope,
token_endpoint_auth_method=token_endpoint_auth_method,
issuer=oauth_server_metadata_url,
server_metadata=oauth_server_metadata,
)
log.info(
f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}'
)
return oauth_client_info
except Exception as e:
log.error(f'Exception building static OAuth client info: {e}')
raise e
class OAuthClientManager:
def __init__(self, app):
self.oauth = OAuth()
@ -495,7 +564,7 @@ class OAuthClientManager:
for connection in connections or []:
if connection.get('type', 'openapi') != 'mcp':
continue
if connection.get('auth_type', 'none') != 'oauth_2.1':
if connection.get('auth_type', 'none') not in ('oauth_2.1', 'oauth_2.1_static'):
continue
server_id = connection.get('info', {}).get('id')
@ -1080,21 +1149,30 @@ class OAuthManager:
log.debug(f'Accepted user roles: {oauth_allowed_roles}')
log.debug(f'Accepted admin roles: {oauth_admin_roles}')
# If any roles are found, check if they match the allowed or admin roles
# If roles are present in the token, they must match; otherwise deny access
if oauth_roles:
# If role management is enabled, and matching roles are provided, use the roles
matched = False
for allowed_role in oauth_allowed_roles:
# If the user has any of the allowed roles, assign the role "user"
if allowed_role in oauth_roles:
log.debug('Assigned user the user role')
role = 'user'
matched = True
break
for admin_role in oauth_admin_roles:
# If the user has any of the admin roles, assign the role "admin"
if admin_role in oauth_roles:
log.debug('Assigned user the admin role')
role = 'admin'
matched = True
break
if not matched:
log.warning(
f'OAuth role management enabled but user roles do not match any allowed/admin roles. '
f'User roles: {oauth_roles}, allowed: {oauth_allowed_roles}, admin: {oauth_admin_roles}'
)
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
if not user:
# If role management is disabled, use the default role for new users
@ -1286,6 +1364,8 @@ class OAuthManager:
kwargs = {}
if auth_manager_config.OAUTH_AUDIENCE:
kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE
if OAUTH_AUTHORIZE_PARAMS:
kwargs.update(OAUTH_AUTHORIZE_PARAMS)
return await client.authorize_redirect(request, redirect_uri, **kwargs)
@ -1317,12 +1397,21 @@ class OAuthManager:
# Try to get userinfo from the token first, some providers include it there
user_data: UserInfo = token.get('userinfo')
# Preserve extra claims from the ID token (e.g. roles, groups for
# Microsoft Entra ID) before the userinfo endpoint possibly overwrites them.
id_token_claims = dict(user_data) if user_data else {}
if (
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
# Merge back ID token claims that the userinfo endpoint doesn't
# return. Only backfill missing keys so userinfo always wins.
if user_data and id_token_claims:
for key, value in id_token_claims.items():
if key not in user_data:
user_data[key] = value
if provider == 'feishu' and isinstance(user_data, dict) and 'data' in user_data:
user_data = user_data['data']
if not user_data:

View file

@ -10,6 +10,8 @@ import copy
import json
# What goes out cannot be taken back. Let it be shaped
# well before it leaves this place.
# inplace function: form_data is modified
def apply_system_prompt_to_body(
system: Optional[str],

View file

@ -197,6 +197,8 @@ def replace_imports(content):
return content
# May the intent of the one who wrote it survive every
# import and transformation, as a deed survives the generations.
def load_tool_module_by_id(tool_id, content=None):
if content is None:
tool = Tools.get_tool_by_id(tool_id)

View file

@ -20,6 +20,9 @@ from open_webui.env import (
log = logging.getLogger(__name__)
# Let not our connections be timed out but deliver them from
# partition. For the cache and the socket and the uptime
# belong to the one who first opened them, now and always.
_CONNECTION_CACHE = {}

View file

@ -6,6 +6,8 @@ from open_webui.utils.misc import (
)
# An honest ledger is worth more than a flattering one.
# Let every cost here be counted true.
def normalize_usage(usage: dict) -> dict:
"""
Normalize usage statistics to standard format.

View file

@ -13,6 +13,8 @@ from open_webui.config import DEFAULT_RAG_TEMPLATE
log = logging.getLogger(__name__)
# Let the right tool be given for the work at hand,
# not the one that flatters, but the one that serves.
def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str:
# Set the task model
task_model_id = default_model_id
@ -239,6 +241,8 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No
# {{prompt:middletruncate:8000}}
# Let the context given here not distort the question,
# but illuminate it, so that the answer serves the one who asked.
def rag_template(template: str, context: str, query: str):
if template.strip() == '':
template = DEFAULT_RAG_TEMPLATE

View file

@ -1,3 +1,4 @@
import base64
import inspect
import logging
import re
@ -44,6 +45,7 @@ from open_webui.utils.access_control import has_access, has_connection_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
ENABLE_FORWARD_USER_INFO_HEADERS,
@ -79,9 +81,11 @@ from open_webui.tools.builtin import (
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
list_knowledge,
view_file,
view_knowledge_file,
view_skill,
tasks,
)
import copy
@ -89,6 +93,8 @@ import copy
log = logging.getLogger(__name__)
# Let no function be called without need, and let what
# it yields justify the cost of running it.
def get_async_tool_function_and_apply_extra_params(function: Callable, extra_params: dict) -> Callable[..., Awaitable]:
sig = inspect.signature(function)
extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
@ -405,12 +411,15 @@ def get_builtin_tools(
model_knowledge = list(model_knowledge or []) + list(folder_knowledge)
if is_builtin_tool_enabled('knowledge'):
if model_knowledge:
# Model has attached knowledge - only allow semantic search within it
# Model has attached knowledge - provide discovery, search and semantic tools
builtin_functions.append(list_knowledge)
builtin_functions.append(search_knowledge_files)
builtin_functions.append(query_knowledge_files)
knowledge_types = {item.get('type') for item in model_knowledge}
if 'file' in knowledge_types or 'collection' in knowledge_types:
builtin_functions.append(view_file)
builtin_functions.append(view_knowledge_file)
if 'note' in knowledge_types:
builtin_functions.append(view_note)
else:
@ -431,7 +440,7 @@ def get_builtin_tools(
builtin_functions.extend([search_chats, view_chat])
# Add memory tools if builtin category enabled AND enabled for this chat
if is_builtin_tool_enabled('memory') and features.get('memory'):
if is_builtin_tool_enabled('memory') and (features.get('memory') or get_model_capability('memory', False)):
builtin_functions.extend(
[
search_memories,
@ -495,6 +504,10 @@ def get_builtin_tools(
if extra_params.get('__skill_ids__'):
builtin_functions.append(view_skill)
# Task management - break down complex work into trackable steps
if is_builtin_tool_enabled('tasks'):
builtin_functions.append(tasks)
for func in builtin_functions:
callable = get_async_tool_function_and_apply_extra_params(
func,
@ -854,9 +867,7 @@ async def get_terminal_system_prompt(
return None
# 2. Fetch system prompt
async with session.get(
f'{base}/system', headers=headers, cookies=cookies or {}
) as resp:
async with session.get(f'{base}/system', headers=headers, cookies=cookies or {}) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('prompt')
@ -950,7 +961,7 @@ async def get_terminal_tools(
terminal_id: str,
user: UserModel,
extra_params: dict,
) -> tuple[dict[str, dict], Optional[str]]:
) -> dict[str, dict] | tuple[dict[str, dict], Optional[str]]:
"""Resolve tools for a terminal server identified by terminal_id.
- Finds the connection in TERMINAL_SERVER_CONNECTIONS
@ -998,6 +1009,13 @@ async def get_terminal_tools(
# auth_type == "none": no Authorization header
system_prompt = server_data.get('system_prompt')
# Use chat_id as the per-session key for cwd tracking
metadata = extra_params.get('__metadata__', {})
session_id = metadata.get('chat_id')
if session_id:
headers['X-Session-Id'] = session_id
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
tools_dict = {}
@ -1232,9 +1250,13 @@ async def execute_tool_server(
if param_name in params:
if param_in == 'path':
path_params[param_name] = params[param_name]
elif param_in == 'query':
if params[param_name] is not None:
query_params[param_name] = params[param_name]
if param_in == 'query':
value = params[param_name]
# Skip empty values for optional params (LLMs sometimes
# pass "" instead of omitting optional parameters).
if value is None or (value == '' and not param.get('required')):
continue
query_params[param_name] = value
final_url = f'{url.rstrip("/")}{route_path}'
for key, value in path_params.items():
@ -1249,7 +1271,7 @@ async def execute_tool_server(
body_params = params
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
) as session:
request_method = getattr(session, http_method.lower())
@ -1269,7 +1291,13 @@ async def execute_tool_server(
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
if content_type.startswith('text/') or not content_type:
response_data = await response.text()
else:
raw = await response.read()
b64 = base64.b64encode(raw).decode()
response_data = f'data:{content_type};base64,{b64}'
response_headers = response.headers
return (response_data, response_headers)
@ -1288,7 +1316,13 @@ async def execute_tool_server(
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
if content_type.startswith('text/') or not content_type:
response_data = await response.text()
else:
raw = await response.read()
b64 = base64.b64encode(raw).decode()
response_data = f'data:{content_type};base64,{b64}'
response_headers = response.headers
return (response_data, response_headers)

View file

@ -8,6 +8,8 @@ from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION
log = logging.getLogger(__name__)
# Let this message reach those for whom it was written, and
# may no network partition deny the word its destination.
async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
try:
log.debug(f'post_webhook: {url}, {message}, {event_data}')

View file

@ -6,7 +6,7 @@ itsdangerous==2.2.0
python-socketio==5.16.1
python-jose==3.5.0
cryptography
cryptography==46.0.5
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.11.0
@ -14,9 +14,9 @@ authlib==1.6.9
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
async-timeout
aiocache
aiofiles
async-timeout==5.0.1
aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.0
Brotli==1.1.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
@ -29,7 +29,7 @@ peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.47
redis
redis==7.4.0
APScheduler==3.11.2
RestrictedPython==8.1
@ -39,11 +39,11 @@ loguru==0.7.3
asgiref==3.11.1
# AI libraries
tiktoken
tiktoken==0.12.0
mcp==1.26.0
openai
anthropic
openai==2.29.0
anthropic==0.86.0
google-genai==1.66.0
langchain==1.2.10
@ -58,7 +58,7 @@ opensearch-py==3.1.0
transformers==5.3.0
sentence-transformers==5.2.3
accelerate
accelerate==1.13.0
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.2
@ -74,15 +74,15 @@ unstructured==0.18.31
nltk==3.9.3
Markdown==3.10.2
beautifulsoup4
beautifulsoup4==4.14.3
pypandoc==1.16.2
pandas==3.0.1
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.2
validators==0.35.0
psutil
sentencepiece
psutil==7.2.2
sentencepiece==0.2.1
soundfile==0.13.1
pillow==12.1.1
@ -97,7 +97,7 @@ black==26.1.0
youtube-transcript-api==1.2.4
pytube==15.0.0
pydub
pydub==0.25.1
ddgs==9.11.3
azure-ai-documentintelligence==1.0.2
@ -106,15 +106,15 @@ azure-storage-blob==12.28.0
azure-search-documents==11.6.0
## Google Drive
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
google-api-python-client==2.193.0
google-auth-httplib2==0.3.0
google-auth-oauthlib==1.3.0
googleapis-common-protos==1.72.0
google-cloud-storage==3.9.0
## Databases
pymongo
pymongo==4.16.0
psycopg2-binary==2.9.11
pgvector==0.4.2

View file

@ -50,7 +50,7 @@ if [ -n "$SPACE_ID" ]; then
echo "Configuring for HuggingFace Space deployment"
if [ -n "$ADMIN_USER_EMAIL" ] && [ -n "$ADMIN_USER_PASSWORD" ]; then
echo "Admin user configured, creating"
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" &
webui_pid=$!
echo "Waiting for webui to start..."
while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do
@ -83,5 +83,5 @@ fi
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \
--host "$HOST" \
--port "$PORT" \
--forwarded-allow-ips '*' \
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
"${ARGS[@]}"

View file

@ -24,6 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" (
IF "%PORT%"=="" SET PORT=8080
IF "%HOST%"=="" SET HOST=0.0.0.0
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS=*"
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
@ -46,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
:: Execute uvicorn
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --workers %UVICORN_WORKERS% --ws auto
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips "%FORWARDED_ALLOW_IPS%" --workers %UVICORN_WORKERS% --ws auto
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto

22
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.10",
"version": "0.8.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.10",
"version": "0.8.12",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -8664,9 +8664,9 @@
"license": "Apache-2.0"
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@ -10035,9 +10035,9 @@
}
},
"node_modules/jspdf": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
@ -13614,9 +13614,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.10",
"version": "0.8.12",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",

View file

@ -14,7 +14,7 @@ dependencies = [
"python-socketio==5.16.1",
"python-jose==3.5.0",
"cryptography",
"cryptography==46.0.5",
"bcrypt==5.0.0",
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.11.0",
@ -22,9 +22,9 @@ dependencies = [
"requests==2.32.5",
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
"async-timeout",
"aiocache",
"aiofiles",
"async-timeout==5.0.1",
"aiocache==0.12.3",
"aiofiles==25.1.0",
"starlette-compress==1.7.0",
"Brotli==1.1.0",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
@ -37,7 +37,7 @@ dependencies = [
"peewee-migrate==1.14.3",
"pycrdt==0.12.47",
"redis",
"redis==7.4.0",
"pytz==2026.1.post1",
"APScheduler==3.11.2",
@ -46,11 +46,11 @@ dependencies = [
"loguru==0.7.3",
"asgiref==3.11.1",
"tiktoken",
"tiktoken==0.12.0",
"mcp==1.26.0",
"openai",
"anthropic",
"openai==2.29.0",
"anthropic==0.86.0",
"google-genai==1.66.0",
"langchain==1.2.10",
@ -66,7 +66,7 @@ dependencies = [
"transformers==5.3.0",
"sentence-transformers==5.2.3",
"accelerate",
"accelerate==1.13.0",
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
"einops==0.8.2",
@ -80,14 +80,15 @@ dependencies = [
"msoffcrypto-tool==6.0.0",
"nltk==3.9.3",
"Markdown==3.10.2",
"beautifulsoup4==4.14.3",
"pypandoc==1.16.2",
"pandas==3.0.1",
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.2",
"validators==0.35.0",
"psutil",
"sentencepiece",
"psutil==7.2.2",
"sentencepiece==0.2.1",
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2",
@ -103,12 +104,12 @@ dependencies = [
"youtube-transcript-api==1.2.4",
"pytube==15.0.0",
"pydub",
"pydub==0.25.1",
"ddgs==9.11.3",
"google-api-python-client",
"google-auth-httplib2",
"google-auth-oauthlib",
"google-api-python-client==2.193.0",
"google-auth-httplib2==0.3.0",
"google-auth-oauthlib==1.3.0",
"googleapis-common-protos==1.72.0",
"google-cloud-storage==3.9.0",
@ -144,7 +145,7 @@ unstructured = [
]
all = [
"pymongo",
"pymongo==4.16.0",
"psycopg2-binary==2.9.11",
"pgvector==0.4.2",
"moto[s3]>=5.0.26",

View file

@ -14,12 +14,19 @@ const packages = [
'seaborn',
'pytz',
'black',
'openai'
'openai',
'openpyxl'
];
// Pure-Python packages whose wheels must be downloaded from PyPI and saved into
// static/pyodide/ so that the browser can install them offline via micropip.
// Packages already provided by the Pyodide distribution (click, platformdirs,
// typing_extensions, etc.) do NOT need to be listed here.
const pypiPackages = ['black', 'pathspec', 'mypy_extensions'];
import { loadPyodide } from 'pyodide';
import { setGlobalDispatcher, ProxyAgent } from 'undici';
import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises';
import { writeFile, readFile, copyFile, readdir, rmdir, access } from 'fs/promises';
/**
* Loading network proxy configurations from the environment variables.
@ -117,6 +124,78 @@ async function copyPyodide() {
}
}
/**
* Download pure-Python wheels from PyPI and save them into static/pyodide/.
* Also injects entries into pyodide-lock.json so that micropip resolves these
* packages from the local server instead of fetching them from the internet.
*/
async function downloadPyPIWheels() {
const lockPath = 'static/pyodide/pyodide-lock.json';
let lockData;
try {
lockData = JSON.parse(await readFile(lockPath, 'utf-8'));
} catch {
console.warn('Could not read pyodide-lock.json, skipping PyPI wheel download');
return;
}
for (const pkg of pypiPackages) {
console.log(`Fetching PyPI metadata for: ${pkg}`);
const res = await fetch(`https://pypi.org/pypi/${pkg}/json`);
if (!res.ok) {
console.error(`Failed to fetch PyPI metadata for ${pkg}: ${res.status}`);
continue;
}
const meta = await res.json();
const version = meta.info.version;
const files = meta.urls || [];
// Find the pure-Python wheel (py3-none-any)
const wheel = files.find(
(f) => f.filename.endsWith('.whl') && f.filename.includes('py3-none-any')
);
if (!wheel) {
console.warn(`No pure-Python wheel found for ${pkg}==${version}, skipping`);
continue;
}
const dest = `static/pyodide/${wheel.filename}`;
// Download wheel if not already present
try {
await access(dest);
console.log(` Already exists: ${wheel.filename}`);
} catch {
console.log(` Downloading: ${wheel.filename}`);
const wheelRes = await fetch(wheel.url);
if (!wheelRes.ok) {
console.error(` Failed to download ${wheel.filename}: ${wheelRes.status}`);
continue;
}
const buffer = Buffer.from(await wheelRes.arrayBuffer());
await writeFile(dest, buffer);
console.log(` Saved: ${dest} (${buffer.length} bytes)`);
}
// Inject into pyodide-lock.json so micropip resolves locally
const normalizedName = pkg.replace(/-/g, '_');
if (!lockData.packages[normalizedName]) {
lockData.packages[normalizedName] = {
name: normalizedName,
version: version,
file_name: wheel.filename,
install_dir: 'site',
sha256: wheel.digests?.sha256 || '',
package_type: 'package',
imports: [normalizedName],
depends: []
};
console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`);
}
}
await writeFile(lockPath, JSON.stringify(lockData, null, 2));
console.log('Updated pyodide-lock.json with PyPI packages');
}
initNetworkProxyFromEnv();
await downloadPackages();
await copyPyodide();
await downloadPyPIWheels();

View file

@ -268,9 +268,10 @@ export const detectTerminalServerType = async (
/**
* Create or update a policy on the orchestrator.
* PUT {url}/api/v1/policies/{policyId}
* Proxied through the Open WebUI backend to keep API keys server-side.
*/
export const putOrchestratorPolicy = async (
token: string,
url: string,
key: string,
policyId: string,
@ -278,18 +279,52 @@ export const putOrchestratorPolicy = async (
): Promise<object | null> => {
let error = null;
const baseUrl = url.replace(/\/$/, '');
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (key) {
headers['Authorization'] = `Bearer ${key}`;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/policy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
policy_id: policyId,
policy_data: policyData
})
})
.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;
}
const res = await fetch(`${baseUrl}/api/v1/policies/${encodeURIComponent(policyId)}`, {
method: 'PUT',
headers,
body: JSON.stringify(policyData)
return res;
};
/**
* Verify a terminal server connection via the backend proxy.
* Used for system/admin connections to avoid CORS issues and API key exposure.
*/
export const verifyTerminalServerConnection = async (token: string, connection: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...connection
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -342,6 +377,7 @@ type RegisterOAuthClientForm = {
url: string;
client_id: string;
client_name?: string;
client_secret?: string;
};
export const registerOAuthClient = async (
@ -441,6 +477,33 @@ export const setCodeExecutionConfig = async (token: string, config: object) => {
return res;
};
export const getModelsDefaults = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models/defaults`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.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 getModelsConfig = async (token: string) => {
let error = null;

View file

@ -2,6 +2,10 @@ import { WEBUI_BASE_URL } from '$lib/constants';
import { convertOpenApiToToolPayload } from '$lib/utils';
import { getOpenAIModelsDirect } from './openai';
const TOOL_SERVER_FETCH_TIMEOUT = 10000;
// Every request sent from here is a petition. May it reach
// the one for whom it was intended, and return answered.
export const getModels = async (
token: string = '',
connections: object | null = null,
@ -302,6 +306,7 @@ export const getToolServerData = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${url}`, {
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT),
method: 'GET',
headers: {
Accept: 'application/json',
@ -322,7 +327,9 @@ export const getToolServerData = async (token: string, url: string) => {
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
if (err?.name === 'TimeoutError') {
error = `Connection to ${url} timed out`;
} else if ('detail' in err) {
error = err.detail;
} else {
error = err;
@ -402,7 +409,9 @@ export const getToolServersData = async (servers: object[]) => {
// Fetch system prompt if the server supports it
try {
const baseUrl = (server?.url ?? '').replace(/\/$/, '');
const configRes = await fetch(`${baseUrl}/api/config`);
const configRes = await fetch(`${baseUrl}/api/config`, {
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT)
});
if (configRes.ok) {
const config = await configRes.json();
if (config?.features?.system) {
@ -410,7 +419,10 @@ export const getToolServersData = async (servers: object[]) => {
if (toolServerToken) {
headers['Authorization'] = `Bearer ${toolServerToken}`;
}
const systemRes = await fetch(`${baseUrl}/system`, { headers });
const systemRes = await fetch(`${baseUrl}/system`, {
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT),
headers
});
if (systemRes.ok) {
const systemData = await systemRes.json();
if (systemData?.prompt) {
@ -442,7 +454,8 @@ export const executeToolServer = async (
url: string,
name: string,
params: Record<string, any>,
serverData: { openapi: any; info: any; specs: any }
serverData: { openapi: any; info: any; specs: any },
sessionId?: string
) => {
let error = null;
@ -519,6 +532,7 @@ export const executeToolServer = async (
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
@ -1404,6 +1418,32 @@ export const getBackendConfig = async () => {
});
if (error) {
// When a forward-auth proxy (e.g. Authentik/Traefik) intercepts the
// request and redirects to an external login page, the browser blocks
// the cross-origin redirect for fetch() and throws a TypeError.
// Detect this by re-fetching with redirect:"manual" — if the server
// responded with a redirect, the probe returns an opaque redirect
// response instead of throwing, confirming the backend is alive but
// an auth proxy is intercepting.
if (error instanceof TypeError) {
try {
const probeRes = await fetch(`${WEBUI_BASE_URL}/api/config`, {
method: 'GET',
credentials: 'include',
redirect: 'manual',
headers: { 'Content-Type': 'application/json' }
});
if (
probeRes.type === 'opaqueredirect' ||
(probeRes.status >= 300 && probeRes.status < 400)
) {
throw { authRedirect: true };
}
} catch (probeErr: any) {
if (probeErr?.authRedirect) throw probeErr;
// Probe also failed — genuine network/backend issue
}
}
throw error;
}

View file

@ -45,11 +45,11 @@ export const getTerminalConfig = async (
return res.json().catch(() => null);
};
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
export const getCwd = async (baseUrl: string, apiKey: string, sessionId?: string): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res || !res.ok) return null;
const json = await res.json().catch(() => null);
return json?.cwd ?? null;
@ -58,13 +58,14 @@ export const getCwd = async (baseUrl: string, apiKey: string): Promise<string |
export const listFiles = async (
baseUrl: string,
apiKey: string,
path: string = '/'
path: string = '/',
sessionId?: string
): Promise<FileEntry[] | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
})
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers })
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
@ -79,12 +80,13 @@ export const listFiles = async (
export const readFile = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch((err) => {
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch((err) => {
console.error('open-terminal readFile error:', err);
return null;
});
@ -106,12 +108,13 @@ export const readFile = async (
export const downloadFileBlob = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res || !res.ok) return null;
@ -120,18 +123,48 @@ export const downloadFileBlob = async (
return { blob, filename };
};
export const archiveFromTerminal = async (
baseUrl: string,
apiKey: string,
paths: string[],
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/archive`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ paths })
}).catch(() => null);
if (!res || !res.ok) return null;
const disposition = res.headers.get('content-disposition') ?? '';
const match = disposition.match(/filename="?([^"]+)"?/);
const filename = match?.[1] ?? 'download.zip';
const blob = await res.blob();
return { blob, filename };
};
export const uploadToTerminal = async (
baseUrl: string,
apiKey: string,
directory: string,
file: File
file: File,
sessionId?: string
): Promise<{ path: string; size: number } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
const body = new FormData();
body.append('file', file);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
headers,
body
})
.then(async (res) => {
@ -148,15 +181,18 @@ export const uploadToTerminal = async (
export const createDirectory = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ path: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ path })
})
.then(async (res) => {
@ -173,12 +209,15 @@ export const createDirectory = async (
export const deleteEntry = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ path: string; type: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` }
headers
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -194,15 +233,18 @@ export const deleteEntry = async (
export const setCwd = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ cwd: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ path })
})
.then(async (res) => {
@ -220,15 +262,18 @@ export const moveEntry = async (
baseUrl: string,
apiKey: string,
source: string,
destination: string
destination: string,
sessionId?: string
): Promise<{ source: string; destination: string } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/move`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ source, destination })
})
.then(async (res) => {

View file

@ -725,7 +725,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();

View file

@ -12,12 +12,16 @@
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { detectTerminalServerType, putOrchestratorPolicy } from '$lib/apis/configs';
import {
detectTerminalServerType,
verifyTerminalServerConnection,
putOrchestratorPolicy
} from '$lib/apis/configs';
import { getTerminalConfig } from '$lib/apis/terminal';
export let show = false;
export let edit = false;
export let admin = false;
export let direct = false;
export let connection = null;
export let onSubmit: Function = () => {};
@ -110,9 +114,14 @@
verifying = true;
try {
if (admin) {
// Admin: detect orchestrator vs terminal
const type = await detectTerminalServerType(_url, key);
if (!direct) {
// System connection: proxy through backend to avoid CORS / key exposure
const result = await verifyTerminalServerConnection(localStorage.token, {
url: _url,
key,
auth_type
});
const type = result?.type ?? null;
if (type) {
serverType = type;
@ -137,7 +146,7 @@
toast.error($i18n.t('Server connection failed'));
}
} else {
// Non-admin: simple terminal verification
// Direct connection: verify from browser
const res = await getTerminalConfig(_url, key);
if (res) {
toast.success($i18n.t('Server connection verified'));
@ -192,9 +201,9 @@
url = url.replace(/\/$/, '');
// Save policy to orchestrator if applicable
if (serverType === 'orchestrator' && admin && policyId) {
if (serverType === 'orchestrator' && !direct && policyId) {
try {
await putOrchestratorPolicy(url, key, policyId, buildPolicyData());
await putOrchestratorPolicy(localStorage.token, url, key, policyId, buildPolicyData());
} catch (err) {
toast.error($i18n.t('Failed to save policy: {{error}}', { error: err }));
return;
@ -202,7 +211,7 @@
}
const result = {
...(admin && id.trim() ? { id: id.trim() } : {}),
...(!direct && id.trim() ? { id: id.trim() } : {}),
url,
key,
name,
@ -210,7 +219,7 @@
auth_type,
enabled: enabled,
config: {
...(admin ? { access_grants: accessGrants } : {})
...(!direct ? { access_grants: accessGrants } : {})
},
// Policy fields
...(serverType ? { server_type: serverType } : {}),
@ -270,7 +279,7 @@
/>
</div>
</div>
{#if admin}
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
@ -368,7 +377,7 @@
</div>
<!-- Policy section (orchestrator only, admin only) -->
{#if serverType === 'orchestrator' && admin}
{#if serverType === 'orchestrator' && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
@ -580,7 +589,7 @@
{$i18n.t('Advanced')}
</button>
{#if admin}
{#if !direct}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
type="button"
@ -662,7 +671,7 @@
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if admin}
{#if !direct}
<option value="session">{$i18n.t('Session')}</option>
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
@ -733,7 +742,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();

View file

@ -58,6 +58,9 @@
let oauthClientInfo = null;
let oauthClientId = '';
let oauthClientSecret = '';
let enable = true;
let loading = false;
let showAdvanced = false;
@ -75,14 +78,22 @@
return;
}
const res = await registerOAuthClient(
localStorage.token,
{
url: url,
client_id: id
},
'mcp'
).catch((err) => {
const formData: { url: string; client_id: string; client_secret?: string } = {
url: url,
client_id: id
};
// For static OAuth, include client credentials
if (auth_type === 'oauth_2.1_static') {
if (!oauthClientId || !oauthClientSecret) {
toast.error($i18n.t('Please enter Client ID and Client Secret'));
return;
}
formData.client_id = id;
formData.client_secret = oauthClientSecret;
}
const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => {
toast.error($i18n.t('Registration failed'));
return null;
});
@ -267,7 +278,11 @@
return;
}
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
if (
type === 'mcp' &&
['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) &&
!oauthClientInfo
) {
toast.error($i18n.t('Please register the OAuth client'));
loading = false;
return;
@ -320,7 +335,10 @@
id: id,
name: name,
description: description,
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}),
...(auth_type === 'oauth_2.1_static'
? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret }
: {})
}
};
@ -345,6 +363,8 @@
description = '';
oauthClientInfo = null;
oauthClientId = '';
oauthClientSecret = '';
enable = true;
functionNameFilterList = '';
@ -369,6 +389,8 @@
name = connection.info?.name ?? '';
description = connection.info?.description ?? '';
oauthClientInfo = connection.info?.oauth_client_info ?? null;
oauthClientId = connection.info?.oauth_client_id ?? '';
oauthClientSecret = connection.info?.oauth_client_secret ?? '';
enable = connection.config?.enable ?? true;
functionNameFilterList = connection.config?.function_name_filter_list ?? '';
@ -607,7 +629,7 @@
</div>
</div>
{#if auth_type === 'oauth_2.1'}
{#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
<div class="flex items-center gap-2">
<div class="flex flex-col justify-end items-center shrink-0">
<Tooltip
@ -660,6 +682,7 @@
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{#if type === 'mcp'}
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
<option value="oauth_2.1_static">{$i18n.t('OAuth 2.1 (Static)')}</option>
{/if}
{/if}
</select>
@ -696,6 +719,19 @@
>
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
</div>
{:else if auth_type === 'oauth_2.1_static'}
<div class="flex flex-col gap-1.5 w-full mt-0.5">
<SensitiveInput
bind:value={oauthClientId}
placeholder={$i18n.t('Client ID')}
required={false}
/>
<SensitiveInput
bind:value={oauthClientSecret}
placeholder={$i18n.t('Client Secret')}
required={false}
/>
</div>
{/if}
</div>
</div>
@ -847,7 +883,7 @@
{/if}
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full mt-2">
<label
@ -872,7 +908,7 @@
{#if type === 'mcp'}
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2 mt-1"
>
<span class="font-medium">
{$i18n.t('Warning')}:
@ -930,7 +966,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();

View file

@ -277,11 +277,11 @@ class Pipe:
content = _content;
await tick();
if (res) {
console.info('Code formatted successfully');
saveHandler();
if (!res) {
console.warn('Code formatting failed or was skipped, saving unformatted code');
}
saveHandler();
}
};
</script>

View file

@ -231,7 +231,7 @@
<img
src={profileImageUrl}
class="size-16 rounded-full object-cover shrink-0"
alt="Profile"
alt={$i18n.t('Profile')}
/>
<div

View file

@ -122,7 +122,6 @@
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<AddTerminalServerModal
admin
bind:show={showAddTerminalModal}
edit={editTerminalIdx !== null}
connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null}

View file

@ -105,7 +105,12 @@
modelsToEnable.forEach((m) => (m.is_active = true));
models = models;
// Sync with server
await Promise.all(modelsToEnable.map((model) => toggleModelById(localStorage.token, model.id)));
await Promise.all(
modelsToEnable.map((model) => upsertModelHandler(model, { is_active: true }, false))
);
await tick();
await init();
};
const disableAllHandler = async () => {
@ -115,8 +120,11 @@
models = models;
// Sync with server
await Promise.all(
modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id))
modelsToDisable.map((model) => upsertModelHandler(model, { is_active: false }, false))
);
await tick();
await init();
};
const showAllHandler = async () => {
@ -127,8 +135,15 @@
});
models = models;
// Sync with server
await Promise.all(modelsToShow.map((model) => upsertModelHandler(model, false)));
await Promise.all(
modelsToShow.map((model) =>
upsertModelHandler(model, { meta: { ...model.meta, hidden: false } }, false)
)
);
toast.success($i18n.t('All models are now visible'));
await tick();
await init();
};
const hideAllHandler = async () => {
@ -139,8 +154,15 @@
});
models = models;
// Sync with server
await Promise.all(modelsToHide.map((model) => upsertModelHandler(model, false)));
await Promise.all(
modelsToHide.map((model) =>
upsertModelHandler(model, { meta: { ...model.meta, hidden: true } }, false)
)
);
toast.success($i18n.t('All models are now hidden'));
await tick();
await init();
};
const downloadModels = async (models) => {
@ -174,10 +196,17 @@
};
}
});
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const upsertModelHandler = async (model, showToast = true) => {
model.base_model_id = null;
const upsertModelHandler = async (model, overrides = {}, showToast = true) => {
model = { ...model, base_model_id: null, ...overrides };
if (workspaceModels.find((m) => m.id === model.id)) {
const res = await updateModelById(localStorage.token, model.id, model).catch((error) => {
@ -200,18 +229,11 @@
return null;
});
if (res && !silent) {
if (res && showToast) {
toast.success($i18n.t('Model updated successfully'));
await init();
}
}
await init();
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const toggleModelHandler = async (model) => {
@ -248,7 +270,7 @@
console.debug(model);
upsertModelHandler(model, false);
upsertModelHandler(model, { meta: model.meta }, false);
toast.success(
model.meta.hidden

View file

@ -31,7 +31,9 @@
onChange={() => onChange(value)}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />

View file

@ -50,7 +50,7 @@
class="min-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
@ -102,7 +102,7 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinModelHandler(model?.id);
}}
@ -123,7 +123,7 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
@ -135,7 +135,7 @@
{#if model?.is_active ?? true}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
@ -147,7 +147,7 @@
{/if}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}

View file

@ -1182,7 +1182,6 @@
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -183,7 +183,9 @@
align="end"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
<span
class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />

View file

@ -310,6 +310,7 @@
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
<!-- {$i18n.t('Last Modified')} -->
{#if orderBy === 'last_active_at'}
<span class="font-normal"

View file

@ -622,6 +622,25 @@
}
}
})
},
{
char: ':',
allowSpaces: false,
command: ({ editor, range, props }) => {
// Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋)
const codepoint = props.id;
const emoji = String.fromCodePoint(parseInt(codepoint, 16));
editor.chain().focus().deleteRange(range).insertContent(emoji).run();
},
render: getSuggestionRenderer(CommandSuggestionList, {
i18n,
onSelect: (e) => {
document.getElementById('chat-input')?.focus();
},
insertTextHandler: insertTextAtCursor,
onUpload: () => {}
})
}
];
loaded = true;
@ -925,7 +944,11 @@
}
// Submit the content when Enter key is pressed
if ((content !== '' || files.length > 0) && e.keyCode === 13 && !e.shiftKey) {
if (
(content !== '' || files.length > 0) &&
e.keyCode === 13 &&
!e.shiftKey
) {
submitHandler();
}
}

View file

@ -70,4 +70,3 @@
</div>
</div>
</Dropdown>

View file

@ -65,6 +65,67 @@
let editedContent = null;
let showDeleteConfirmDialog = false;
// Swipe-to-reply state
let swipeStartX = 0;
let swipeStartY = 0;
let swipeOffsetX = 0;
let isSwiping = false;
let swipeLocked = false; // locked to horizontal once determined
let swipeMessageEl: HTMLElement | null = null;
const SWIPE_THRESHOLD = 60;
const SWIPE_MAX = 100;
const SWIPE_DEAD_ZONE = 10;
const handleTouchStart = (e: TouchEvent) => {
if (disabled || edit || !onReply) return;
const touch = e.touches[0];
swipeStartX = touch.clientX;
swipeStartY = touch.clientY;
swipeOffsetX = 0;
isSwiping = false;
swipeLocked = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (disabled || edit || !onReply) return;
const touch = e.touches[0];
const deltaX = touch.clientX - swipeStartX;
const deltaY = touch.clientY - swipeStartY;
// Determine swipe direction from dead zone
if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) {
if (Math.abs(deltaY) > Math.abs(deltaX)) {
// Vertical scroll — abort swipe tracking
isSwiping = false;
swipeLocked = true;
return;
}
// Horizontal swipe — lock in
swipeLocked = true;
isSwiping = true;
}
if (!isSwiping) return;
// Only allow right swipe
const clampedX = Math.max(0, deltaX);
// Dampen the motion beyond threshold for a rubber-band feel
swipeOffsetX = clampedX <= SWIPE_THRESHOLD
? clampedX
: SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX);
};
const handleTouchEnd = () => {
if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) {
onReply(message);
}
swipeOffsetX = 0;
isSwiping = false;
swipeLocked = false;
};
const loadMessageData = async () => {
if (message && message?.data === true) {
const res = await getMessageData(localStorage.token, channel?.id, message.id);
@ -92,20 +153,39 @@
{#if message}
<div
id="message-{message.id}"
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {className
? className
: `px-5 ${
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}"
class="swipe-reply-wrapper relative"
on:touchstart={handleTouchStart}
on:touchmove={handleTouchMove}
on:touchend={handleTouchEnd}
>
<!-- Swipe reply indicator -->
{#if swipeOffsetX > 0}
<div
class="swipe-reply-indicator"
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 + Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
>
<div class="swipe-reply-icon" class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}>
<ArrowUpLeftAlt className="size-5" />
</div>
</div>
{/if}
<div
id="message-{message.id}"
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
? className
: `px-5 ${
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}"
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0 ? '' : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
>
{#if !edit && !disabled}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
@ -545,6 +625,7 @@
{/if}
</div>
</div>
</div>
</div>
{/if}
@ -561,4 +642,48 @@
background-color: transparent;
}
}
/* Swipe-to-reply styles */
.swipe-reply-wrapper {
touch-action: pan-y;
}
.swipe-reply-indicator {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
pointer-events: none;
}
.swipe-reply-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(128, 128, 128, 0.15);
color: rgba(128, 128, 128, 0.8);
transition: background-color 0.15s, color 0.15s;
}
.swipe-reply-icon--active {
background-color: rgba(59, 130, 246, 0.2);
color: rgb(59, 130, 246);
}
:global(.dark) .swipe-reply-icon {
background-color: rgba(200, 200, 200, 0.1);
color: rgba(200, 200, 200, 0.6);
}
:global(.dark) .swipe-reply-icon--active {
background-color: rgba(96, 165, 250, 0.2);
color: rgb(96, 165, 250);
}
</style>

View file

@ -153,7 +153,9 @@
{/if}
</div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
<div
class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
>
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
@ -164,7 +166,7 @@
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
@ -173,17 +175,17 @@
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
<button
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
class=" flex cursor-pointer shrink-0 py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Count"
type="button"
on:click={() => {
showChannelInfoModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-sm">
<div class="text-sm shrink-0">
{channel.user_count}
</div>
</div>
@ -193,30 +195,32 @@
{/if}
{#if $user !== undefined}
<UserMenu
className="w-[240px]"
role={$user?.role}
help={true}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
<div>
<UserMenu
className="w-[240px]"
role={$user?.role}
help={true}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<div class=" self-center">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
</div>
{/if}
</div>
</div>

View file

@ -144,6 +144,7 @@
let selectedToolIds = [];
let selectedFilterIds = [];
let pendingOAuthTools = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
@ -158,6 +159,8 @@
let chat = null;
let tags = [];
let chatTasks = [];
let history = {
messages: {},
currentId: null
@ -171,8 +174,6 @@
let files = [];
let params = {};
$: if (chatIdProp) {
navigateHandler();
}
@ -276,6 +277,7 @@
const resetInput = () => {
selectedToolIds = [];
selectedFilterIds = [];
pendingOAuthTools = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
@ -300,11 +302,29 @@
if (model) {
// Set Default Tools
if (model?.info?.meta?.toolIds) {
selectedToolIds = [
const defaultIds = [
...new Set(
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
)
];
// Separate unauthenticated OAuth tools
const unauthed = [];
const authed = [];
for (const id of defaultIds) {
const tool = $tools.find((t) => t.id === id);
if (tool && tool.authenticated === false) {
const parts = id.split(':');
const serverId = parts.at(-1) ?? id;
const authType =
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
} else {
authed.push(id);
}
}
selectedToolIds = authed;
pendingOAuthTools = unauthed;
} else if ($settings?.tools) {
selectedToolIds = $settings.tools;
} else {
@ -431,6 +451,8 @@
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:message:tasks') {
chatTasks = data.tasks;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
@ -962,10 +984,9 @@
let contents = [];
messages.forEach((message) => {
if (message?.role !== 'user' && message?.content) {
const {
codeBlocks: codeBlocks,
htmlGroups: htmlGroups
} = getCodeBlockContents(message.content);
const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents(
message.content
);
if (htmlGroups && htmlGroups.length > 0) {
htmlGroups.forEach((group) => {
@ -1139,6 +1160,7 @@
chatFiles = [];
params = {};
taskIds = null;
chatTasks = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
@ -1174,6 +1196,15 @@
.filter((id) => id);
}
// Restore tool selection after OAuth redirect
const pendingToolId = sessionStorage.getItem('pendingOAuthToolId');
if (pendingToolId) {
sessionStorage.removeItem('pendingOAuthToolId');
if (!selectedToolIds.includes(pendingToolId)) {
selectedToolIds = [...selectedToolIds, pendingToolId];
}
}
if ($page.url.searchParams.get('call') === 'true') {
showCallOverlay.set(true);
showControls.set(true);
@ -1242,6 +1273,9 @@
params = chatContent?.params ?? {};
chatFiles = chatContent?.files ?? [];
// Load tasks from chat-level DB field
chatTasks = chat?.tasks ?? [];
autoScroll = true;
await tick();
@ -1744,6 +1778,10 @@
selectedModels = _selectedModels;
}
if (pendingOAuthTools.length > 0) {
toast.warning($i18n.t('Please connect all required integrations before sending a message'));
return;
}
if (userPrompt === '' && files.length === 0) {
toast.error($i18n.t('Please enter a prompt'));
return;
@ -1786,10 +1824,7 @@
const _files = structuredClone(files);
chatRequestQueues.update((q) => ({
...q,
[$chatId]: [
...(q[$chatId] ?? []),
{ id: uuidv4(), prompt: userPrompt, files: _files }
]
[$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }]
}));
// Clear input
messageInput?.setText('');
@ -1990,9 +2025,6 @@
}
})
);
currentChatPage.set(1);
chats.set(await getChatList(localStorage.token, $currentChatPage));
};
const getFeatures = () => {
@ -2597,8 +2629,6 @@
params: params,
files: chatFiles
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
}
};
@ -2839,6 +2869,7 @@
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
{pendingOAuthTools}
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
@ -2849,6 +2880,7 @@
{createMessagePair}
{onUpload}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
onQueueSendNow={async (id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
@ -2926,6 +2958,7 @@
bind:atSelectedModel
bind:showCommands
bind:dragged
{pendingOAuthTools}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}

View file

@ -95,10 +95,12 @@
showControls.set(true);
}
// Auto-open Files tab when a terminal is selected
// Auto-open Files tab when a terminal is selected (suppress panel open when full-screen)
$: if ($selectedTerminalId) {
activeTab = 'files';
showControls.set(true);
if (largeScreen) {
showControls.set(true);
}
}
// Attach a terminal file to the chat input
@ -289,7 +291,7 @@
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
@ -360,7 +362,7 @@
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} />
<FileNav onAttach={handleTerminalAttach} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
@ -435,7 +437,7 @@
<!-- Controls + Files tabs -->
<div class="flex flex-col h-full min-h-0">
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
@ -511,7 +513,7 @@
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { getContext } from 'svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Tooltip from '../../common/Tooltip.svelte';
const i18n = getContext('i18n');
export let count: number = 0;
export let hasFiles: boolean = false;
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
export let onSelectAll: () => void = () => {};
export let onClear: () => void = () => {};
</script>
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/50 shrink-0">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 flex-1 truncate">
{$i18n.t('{{count}} selected', { count })}
</span>
<Tooltip content={$i18n.t('Select All')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onSelectAll}
aria-label={$i18n.t('Select All')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDownload}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Deselect')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onClear}
aria-label={$i18n.t('Deselect')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
@ -7,6 +7,7 @@
import Folder from '../../icons/Folder.svelte';
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Pencil from '../../icons/Pencil.svelte';
const i18n = getContext('i18n');
@ -19,13 +20,109 @@
export let onDownload: (path: string) => void = () => {};
export let onDelete: (path: string, name: string) => void = () => {};
export let onMove: (source: string, destFolder: string) => void = () => {};
export let onRename: (oldPath: string, newName: string) => void = () => {};
// ── Selection ─────────────────────────────────────────────────────────
export let selected: boolean = false;
export let selectionMode: boolean = false;
export let selectedPaths: Set<string> = new Set();
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
export let onLongPress: () => void = () => {};
let dragOverFolder = false;
// ── Rename state ─────────────────────────────────────────────────────
let renaming = false;
let renameValue = '';
let renameInput: HTMLInputElement;
const startRename = async () => {
renameValue = entry.name;
renaming = true;
await tick();
renameInput?.focus();
if (entry.type === 'file') {
const dotIdx = entry.name.lastIndexOf('.');
renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length);
} else {
renameInput?.select();
}
};
const submitRename = () => {
const newName = renameValue.trim();
renaming = false;
if (!newName || newName === entry.name) return;
onRename(`${currentPath}${entry.name}`, newName);
};
const cancelRename = () => {
renaming = false;
renameValue = '';
};
// ── Long-press for touch selection ───────────────────────────────────
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let didLongPress = false;
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
didLongPress = false;
longPressTimer = setTimeout(() => {
didLongPress = true;
onLongPress();
onSelect(entry, e as any);
}, 500);
};
const onPointerUp = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
const onPointerCancel = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
onDestroy(() => {
if (longPressTimer) clearTimeout(longPressTimer);
});
// ── Click handler ────────────────────────────────────────────────────
const handleClick = (e: MouseEvent) => {
if (renaming) return;
if (didLongPress) {
didLongPress = false;
return;
}
// Modifier click → toggle/range select
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
onSelect(entry, e);
return;
}
// In selection mode (touch) → toggle select
if (selectionMode) {
onSelect(entry, e);
return;
}
// Normal click → open
onOpen(entry);
};
</script>
<li class="group">
<div
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition
class="w-full flex items-center transition
{selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}
{dragOverFolder
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
: ''}"
@ -51,11 +148,11 @@
dragOverFolder = false;
try {
const data = JSON.parse(raw);
if (data.path) {
const destFolder = `${currentPath}${entry.name}/`;
// Don't allow dropping a folder onto itself
if (data.path + '/' === destFolder || data.path === destFolder) return;
onMove(data.path, destFolder);
const paths = data.paths || (data.path ? [data.path] : []);
const destFolder = `${currentPath}${entry.name}/`;
for (const p of paths) {
if (p + '/' === destFolder || p === destFolder) continue;
onMove(p, destFolder);
}
} catch {}
}}
@ -65,12 +162,26 @@
draggable={true}
on:dragstart={(e) => {
const filePath = `${currentPath}${entry.name}`;
// Internal move data
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
);
// Keep existing chat-attachment drag for files
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ paths: [...selectedPaths] })
);
// Custom drag ghost showing count
const ghost = document.createElement('div');
ghost.style.cssText =
'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:8px;background:#374151;color:#fff;font-size:12px;white-space:nowrap;pointer-events:none;';
ghost.textContent = `${selectedPaths.size} items`;
document.body.appendChild(ghost);
e.dataTransfer?.setDragImage(ghost, 0, 0);
requestAnimationFrame(() => ghost.remove());
} else {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
);
}
if (entry.type === 'file') {
e.dataTransfer?.setData(
'application/x-terminal-file',
@ -83,8 +194,38 @@
);
}
}}
on:click={() => onOpen(entry)}
on:pointerdown={onPointerDown}
on:pointerup={onPointerUp}
on:pointercancel={onPointerCancel}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
>
{#if selectionMode || selected}
<!-- Checkbox indicator -->
<div
class="size-3.5 shrink-0 rounded border transition-colors flex items-center justify-center
{selected
? 'bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 text-white'
: 'border-gray-300 dark:border-gray-600'}"
>
{#if selected}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-2.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
{/if}
{#if entry.type === 'directory'}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else}
@ -103,10 +244,31 @@
/>
</svg>
{/if}
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
{entry.name}
</span>
{#if entry.type === 'file' && entry.size !== undefined}
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitRename();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
on:blur={submitRename}
on:click|stopPropagation
/>
{:else}
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
{entry.name}
</span>
{/if}
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
{/if}
</button>
@ -125,31 +287,45 @@
<div
class="min-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
>
{#if entry.type !== 'directory'}
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
on:click={(e) => {
e.stopPropagation();
onDownload(`${currentPath}${entry.name}`);
}}
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
onDownload(path);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
{/if}
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
on:click={(e) => {
e.stopPropagation();
startRename();
}}
>
<Pencil className="size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</button>
<button
type="button"

View file

@ -18,8 +18,15 @@
export let onNewFolder: () => void = () => {};
export let onNewFile: () => void = () => {};
export let onUploadFiles: (files: File[]) => void = () => {};
export let onDownloadDir: () => void = () => {};
export let onMove: (source: string, destFolder: string) => void = () => {};
// Back / forward navigation
export let canGoBack = false;
export let canGoForward = false;
export let onGoBack: () => void = () => {};
export let onGoForward: () => void = () => {};
let dragOverCrumb: number | null = null;
let uploadInput: HTMLInputElement;
@ -32,6 +39,56 @@
</script>
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 p-1 rounded transition {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 p-1 rounded transition {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<div
bind:this={breadcrumbEl}
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
@ -66,7 +123,8 @@
dragOverCrumb = null;
try {
const data = JSON.parse(raw);
if (data.path) onMove(data.path, crumb.path);
const paths = data.paths || (data.path ? [data.path] : []);
for (const p of paths) onMove(p, crumb.path);
} catch {}
}}
>
@ -121,6 +179,27 @@
<FilePlusAlt className="size-3.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDownloadDir}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Upload')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"

View file

@ -49,7 +49,7 @@
let editTextarea: HTMLTextAreaElement;
// Reset edit state when switching files
$: selectedFile, resetEdit();
$: (selectedFile, resetEdit());
const resetEdit = () => {
editing = false;
@ -242,7 +242,7 @@
}
export let showRaw = false;
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
$: (selectedFile, (showRaw = false)); // reset to preview mode when switching files
// Auto-switch to raw/editor mode for empty previewable files so the user
// can start editing immediately instead of seeing a blank preview.
@ -418,7 +418,7 @@
title="HTML Preview"
/>
{:else if isHtml && showRaw}
<div class="h-full">
<div class="absolute inset-0">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
@ -431,7 +431,7 @@
{@html renderedHtml}
</div>
{:else if isMarkdown && showRaw}
<div class="h-full">
<div class="absolute inset-0">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
@ -488,7 +488,7 @@
})}
</div>
{:else if isCode && !showRaw}
<div class="h-full">
<div class="absolute inset-0">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}

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