diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 1c43cb2058..34663b5eda 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,5 +1,5 @@ name: Bug Report -description: Create a detailed bug report to help us improve Open WebUI. +description: Tell us what broke in Open WebUI. title: 'issue: ' labels: ['bug', 'triage'] body: @@ -8,35 +8,28 @@ body: value: | # Bug Report - ## Important Notes + Use this for real, reproducible bugs. A clear issue is the most useful contribution: include the affected workflow, the expected result, the actual result, and the details needed for someone else to reproduce it. - - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.** + Before submitting, search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions). The issue may already be reported or fixed on `dev`. - - Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!** + Please do not open a code pull request for this report unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Actionable reproduction details are the most useful next step. - - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. - - - **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality. - - - **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated! - - - **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security). + Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead. - type: checkboxes id: issue-check attributes: - label: Check Existing Issues - description: Confirm that you’ve checked for existing reports before submitting a new one. + label: Before Submitting options: - - label: I have searched for any existing and/or related issues. + - label: I searched open and closed issues and discussions for an existing report. required: true - - label: I have searched for any existing and/or related discussions. + - label: I checked whether this is already fixed on the `dev` branch or latest source. required: true - - label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!). + - label: I understand that maintainers want a well-written issue before any code pull request. required: true - - label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source. + - label: I am using the latest available version of Open WebUI for my install method. required: true - - label: I am using the latest version of Open WebUI. + - label: This is not a security vulnerability. required: true - type: dropdown @@ -45,9 +38,9 @@ body: label: Installation Method description: How did you install Open WebUI? options: - - Git Clone - - Pip Install - Docker + - Pip Install + - Git Clone - Other validations: required: true @@ -56,67 +49,51 @@ body: id: open-webui-version attributes: label: Open WebUI Version - description: Specify the version (e.g., v0.11.0) + description: Specify the version, commit, or image tag. + placeholder: v0.11.0, dev commit SHA, or Docker tag validations: required: true - - type: input - id: ollama-version - attributes: - label: Ollama Version (if applicable) - description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0) - validations: - required: false - - type: input id: operating-system attributes: label: Operating System - description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13) + description: Specify the OS and version. + placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13 validations: required: true - type: input id: browser attributes: - label: Browser (if applicable) - description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3) + label: Browser + description: If the bug appears in the browser, include browser and version. + placeholder: Chrome 151.0, Firefox 153.0.3 validations: required: false - - type: checkboxes - id: confirmation + - type: input + id: ollama-version attributes: - label: Confirmation - description: Ensure the following prerequisites have been met. - options: - - label: I have read and followed all instructions in `README.md`. - required: true - - label: I am using the latest version of **both** Open WebUI and Ollama. - required: true - - label: I have included the browser console logs. - required: true - - label: I have included the Docker container logs. - required: true - - label: I have **provided every relevant configuration, setting, and environment variable used in my setup.** - required: true - - label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc). - required: true - - label: | - I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps: - - Start with the initial platform/version/OS and dependencies used, - - Specify exact install/launch/configure commands, - - List URLs visited, user input (incl. example values/emails/passwords if needed), - - Describe all options and toggles enabled or changed, - - Include any files or environmental changes, - - Identify the expected and actual result at each stage, - - Ensure any reasonably skilled user can follow and hit the same issue. - required: true + label: Ollama Version + description: Include this if Ollama is involved. + placeholder: v0.32.5 + validations: + required: false + + - type: textarea + id: summary + attributes: + label: Summary + description: What is wrong, in a few sentences? + validations: + required: true + - type: textarea id: expected-behavior attributes: label: Expected Behavior - description: Describe what should have happened. + description: What should have happened? validations: required: true @@ -124,7 +101,7 @@ body: id: actual-behavior attributes: label: Actual Behavior - description: Describe what actually happened. + description: What actually happened? validations: required: true @@ -132,32 +109,21 @@ body: id: reproduction-steps attributes: label: Steps to Reproduce - description: | - Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables. - **If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.** - **If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.** - + description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug. placeholder: | - Example (include every detail): - 1. Start with a clean Ubuntu 26.04 install. - 2. Install Docker v29.7.1 and start the service. - 3. Clone the Open WebUI repo (git clone ...). - 4. Use the Docker Compose file without modifications. - 5. Open browser Chrome 151.0 in incognito mode. - 6. Go to http://localhost:8080 and log in with user "test@example.com". - 7. Set the language to "English" and theme to "Dark". - 8. Attempt to connect to Ollama at "http://localhost:11434". - 9. Observe that the error message "Connection refused" appears at the top right. - - Please list each step carefully and include all relevant configuration, settings, and options. + 1. Start Open WebUI with ... + 2. Configure ... + 3. Open ... + 4. Click ... + 5. See ... validations: required: true + - type: textarea id: logs-screenshots attributes: - label: Logs & Screenshots - description: Include relevant logs, errors, or screenshots to help diagnose the issue. - placeholder: 'Attach logs from the browser console, Docker logs, or error messages.' + label: Logs, Screenshots, and Config + description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so. validations: required: true @@ -165,13 +131,6 @@ body: id: additional-info attributes: label: Additional Information - description: Provide any extra details that may assist in understanding the issue. + description: Anything else that might help us understand the report. validations: required: false - - - type: markdown - attributes: - value: | - ## Note - **If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue. - Thank you for contributing to Open WebUI! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 0972f9cb9a..87a915960c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,78 +1,73 @@ name: Feature Request -description: Suggest a new feature or improvement +description: Describe what you would like Open WebUI to support. title: 'feat: ' labels: ['triage'] body: - type: markdown attributes: value: | - ## Before Submitting + # Feature Request - Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead. + Describe the requested behavior, the problem it solves, and any examples, mockups, screenshots, or workflows that clarify the request. A clear issue or discussion is the most useful contribution. - ### Scope Guidelines + Search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) before submitting. If the request needs broad product, UX, architecture, compatibility, or maintenance discussion, please start in [Discussions](https://github.com/open-webui/open-webui/discussions) so the community can weigh in. - Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items. + Please do not open a code pull request for this request unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Clear product context is the most useful next step. - If your request might impact the broader community, please open a Discussion first so others can weigh in on the design. - - ### Be Respectful - - Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy. - - ### Contributing - - If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality. - - ### Reproducibility - - If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance. + Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead. - type: checkboxes - id: existing-issue + id: existing-request attributes: - label: Check Existing Issues - description: Confirm you have searched for similar requests. + label: Before Submitting options: - - label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request. + - label: I searched open and closed issues and discussions for an existing request. required: true - - label: I have checked whether this request is already implemented on the `dev` branch or in the latest source. + - label: I checked whether this already exists on the `dev` branch or latest source. required: true - - - type: checkboxes - id: feature-scope - attributes: - label: Verify Feature Scope - description: Confirm this request belongs in Issues rather than Discussions. - options: - - label: I believe this feature request is appropriately scoped for the Issues section as described above. + - label: I understand that maintainers want a well-written issue or discussion before any code pull request. + required: true + - label: This request is not a security vulnerability. required: true - type: textarea id: problem-description attributes: - label: Problem Description - description: Is this related to a problem? Describe the pain point clearly. - placeholder: "e.g., I'm frustrated when..." + label: Problem + description: What is missing, frustrating, confusing, or unnecessarily hard today? + placeholder: "I'm trying to..., but..." validations: required: true - type: textarea - id: solution-description + id: desired-behavior attributes: - label: Proposed Solution - description: Describe what you would like to happen. + label: Desired Behavior + description: What would you like to happen instead? + placeholder: "I would like Open WebUI to..." validations: required: true + - type: textarea + id: why-it-matters + attributes: + label: Why This Matters + description: Who benefits, and what workflow does this unlock or improve? + validations: + required: true + + - type: textarea + id: examples + attributes: + label: Examples or References + description: Add mockups, screenshots, links, prompts, workflows, or examples from other tools. + validations: + required: false + - type: textarea id: alternatives-considered attributes: - label: Alternatives Considered - description: Describe any alternative solutions or workarounds you have considered. - - - type: textarea - id: additional-context - attributes: - label: Additional Context - description: Add any other context, mockups, or screenshots about the feature request. + label: Alternatives or Workarounds + description: What have you tried instead, if anything? + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 27f795ace7..a568080607 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,126 +1,94 @@ -# Pull Request Checklist +# Pull Request -### Do not open a pull request as the first step. +Thanks for wanting to improve Open WebUI. The most useful contribution is usually a clear, well-written Issue, not an unsolicited code pull request. -For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for. +Open a code pull request only when a maintainer asks for one, or when the change is only i18n/localization. For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues). For feature requests, UI/UX changes, behavior changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). -For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed. +Before continuing, make sure the linked Issue or Discussion explains the user-facing problem, the expected outcome, the affected workflow, and any examples, logs, screenshots, constraints, or reproduction details needed for maintainers to evaluate it. -If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch. +If you have implementation notes, include them as reference in the Issue or Discussion. If you want to share code as reference, include it there as a local diff, patch, or branch note. Do not open a pull request for reference code. -Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful. +Unsolicited PRs may be closed without review, especially when they introduce product, architecture, compatibility, dependency, or maintenance decisions that have not been discussed. -We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation. +## Checklist -Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude. +- [ ] This PR targets the `dev` branch. +- [ ] This PR links to a well-described, confirmed Issue or active Discussion: `Closes #___` / `Relates to #___`. +- [ ] A maintainer explicitly asked me to open this PR, or this PR only updates i18n/localization. +- [ ] The change is one logical unit with no unrelated commits. +- [ ] I matched nearby code patterns and avoided unnecessary new settings, abstractions, or dependencies. +- [ ] I manually tested the changed workflow and any nearby behavior that could be affected. +- [ ] I updated relevant docs, including the [Open WebUI Docs Repository](https://github.com/open-webui/docs), if needed. +- [ ] I added screenshots for UI changes, and a recording when motion or interaction matters. +- [ ] I reviewed any AI-generated code before submitting it. +- [ ] The PR title uses one of the prefixes listed below. - +## Summary -**Before submitting, make sure you've checked and filled out the following:** +Describe the change, the problem it solves, and the impact on users. -- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`. -- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion. -- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.** -- [ ] **Description:** A concise description of the changes is provided below. -- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom. -- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs). -- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented. -- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable. -- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended. -- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately. -- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards. -- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first. -- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits. -- [ ] **Title Prefix:** The PR title uses one of the following prefixes: - - **BREAKING CHANGE**: Changes affecting backward compatibility - - **build**: Build system or dependency changes - - **ci**: CI/CD workflow changes - - **chore**: Refactoring, cleanup, or non-functional changes - - **docs**: Documentation additions or updates - - **feat**: New features or enhancements - - **fix**: Bug fixes or corrections - - **i18n**: Internationalization or localization changes - - **perf**: Performance improvements - - **refactor**: Code restructuring +## Testing -# Changelog Entry +List the exact manual checks you ran. Include commands, setup details, screenshots, or recordings where helpful. -### Description - -- [Describe the changes, including motivation and impact] +## Changelog Entry ### Added -- [New features, functionalities, or additions] +- ### Changed -- [Changes, updates, refactorings, or optimizations] - -### Deprecated - -- [Deprecated functionality or features] - -### Removed - -- [Removed features, files, or functionalities] +- ### Fixed -- [Bug fixes or corrections] +- + +### Removed + +- ### Security -- [Security-related changes or vulnerability fixes] +- ### Breaking Changes -- **BREAKING CHANGE**: [Changes affecting compatibility or functionality] +- ---- +## Additional Context -### Additional Information +Add anything maintainers should know before review. -- [Any additional context, notes, or references to related issues/commits] - -### Screenshots or Videos - -- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.] - -### Contributor License Agreement +## Contributor License Agreement - [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. - -> [!NOTE] -> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in. diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index f14afd6a58..ab9283fa02 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -311,7 +311,6 @@ jobs: runs-on: ubuntu-latest if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }} needs: [merge] - continue-on-error: true strategy: fail-fast: false matrix: @@ -403,5 +402,16 @@ jobs: [ -z "$TAG" ] && continue DEST="${DOCKERHUB_IMAGE}:${TAG}" echo " -> ${DEST}" - docker buildx imagetools create -t "${DEST}" "${SOURCE}" + for ATTEMPT in 1 2 3; do + if docker buildx imagetools create -t "${DEST}" "${SOURCE}" && \ + docker buildx imagetools inspect "${DEST}"; then + break + fi + if [ "${ATTEMPT}" = "3" ]; then + echo "Failed to copy ${DEST} after ${ATTEMPT} attempts" + exit 1 + fi + echo "Copy attempt ${ATTEMPT} for ${DEST} failed, retrying in 15s..." + sleep 15 + done done <<< "${{ steps.tags.outputs.tags }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index b5dbe95b68..e3b55599fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ 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.11.2] - 2026-08-31 + +### Added + +- 🖼️ **Richer previews for terminal files.** Word documents and slide decks produced in the terminal are now previewed as the finished document rather than an approximation, and every document preview gains a page strip down the side with numbered thumbnails you can click to jump straight to a page, and the notice warning that a preview might differ from the download is gone now that it does not. [Commit](https://github.com/open-webui/open-webui/commit/061fb434328ea8365cc4961519e8adbe5ac7b0c1), [Commit](https://github.com/open-webui/open-webui/commit/9d95a0148b3180ba48186bc6928713f969a69047), [Commit](https://github.com/open-webui/open-webui/commit/ae549d3e4af672a63a809e8fe2a6c91e0bfc1067), [Commit](https://github.com/open-webui/open-webui/commit/78d8c9166f1b6626b9aa559a812183ce216eef4f), [Commit](https://github.com/open-webui/open-webui/commit/492ccf3ac0d9aec1f3f2bc6825a3e6d3b32aa0d1) +- ⚡ **Less overhead on every message.** Deployments without pipelines configured, which is the default, no longer pay setup work for them on each chat message and background task. [#29146](https://github.com/open-webui/open-webui/pull/29146) +- 🏎️ **Lighter model list refreshes.** On deployments running several workers, a refresh that finds the model list unchanged no longer has every worker rewrite the whole list to the shared cache, cutting the work and the traffic each refresh costs. [#29264](https://github.com/open-webui/open-webui/pull/29264) +- 🧵 **Smoother busy websocket servers.** Large Redis-backed websocket deployments now spend far less time sweeping old sessions, reading their own session data back from Redis, and checking every outgoing websocket message for file attachments Open WebUI does not send, so channel posts, collaboration updates, heartbeats, and live chat updates put less load on busy servers. [#28835](https://github.com/open-webui/open-webui/pull/28835), [#28180](https://github.com/open-webui/open-webui/pull/28180) +- 🧰 **A new request filter step.** Filter authors can now use `request` to adjust the payload right before each model call, including follow-up calls after tool use, while existing `inlet`, `stream`, and `outlet` filters keep working as before. [Commit](https://github.com/open-webui/open-webui/commit/2daa610cba514dfe2eae008b1fc440d4ddb0e470), [Commit](https://github.com/open-webui/open-webui/commit/2a4ef46ac86ada3447eb6d6c50fd4c1241eebd4d) +- 🤏 **More room in file previews on touch screens.** File previews on phones and tablets no longer show zoom buttons that sit over an already small preview, leaving pinch to zoom to do the job. [#29176](https://github.com/open-webui/open-webui/pull/29176), [#29152](https://github.com/open-webui/open-webui/issues/29152) +- 👆 **Resizing panels by touch.** The divider beside the main sidebar or a side panel can now be dragged on a touchscreen or with a stylus, and a drag made with the mouse keeps following the pointer when it leaves the window. [Commit](https://github.com/open-webui/open-webui/commit/cfa2d25317b426c80dca43829afbf113eeccc365), [Commit](https://github.com/open-webui/open-webui/commit/f0ffa7508e75946434794941378675e0c7151eae), [Commit](https://github.com/open-webui/open-webui/commit/e250be48ee16d71202651e4109a175910a6519bb) +- 🔤 **Choice of interface font.** Interface settings now offer a font family field where you can name a font installed on your computer and watch it apply across the interface as you type, with an empty field returning to the standard font. [Commit](https://github.com/open-webui/open-webui/commit/b9765fe97943831e17a68490cd01c70a6ccf1c43) +- ♿ **Wider accessibility mode coverage.** Accessibility mode now lifts the contrast of more of the interface, including muted text, table text, icons, the colours elements change to on hover, and the highlight behind sidebar and menu items you point at, so low-contrast areas meet the level the accessibility guidelines ask for in both light and dark themes, and the close button on the folder dialog now announces itself to screen readers, as do document and slide previews, which can now be reached with the keyboard. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e), [Commit](https://github.com/open-webui/open-webui/commit/797b4c51d1e31f614c8e6eba14ee347c61ad0c7a), [#29160](https://github.com/open-webui/open-webui/pull/29160), [Commit](https://github.com/open-webui/open-webui/commit/e4694f82ebd2e916c099e5e921c0ca8445ba88a7), [Commit](https://github.com/open-webui/open-webui/commit/120409ef016c1e866247e2aa1eba7606f797a20c) +- 🌐 **Translation updates.** Polish, Simplified Chinese, German, Irish, Catalan, and Portuguese (Brazil) translations were expanded, corrected, and brought up to date with newer interface text. [#29029](https://github.com/open-webui/open-webui/pull/29029), [#29043](https://github.com/open-webui/open-webui/pull/29043), [#29108](https://github.com/open-webui/open-webui/pull/29108), [#29151](https://github.com/open-webui/open-webui/pull/29151), [#29184](https://github.com/open-webui/open-webui/pull/29184), [#29179](https://github.com/open-webui/open-webui/pull/29179), [#29247](https://github.com/open-webui/open-webui/pull/29247), [#29217](https://github.com/open-webui/open-webui/pull/29217) + +### Fixed + +- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section. Some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security) +- 💥 **Replies stop breaking mid-chat.** A conversation no longer fails in the browser and stops showing the assistant reply until you reload, and chats already saved in that state display and can be edited again. [#29250](https://github.com/open-webui/open-webui/pull/29250), [#29244](https://github.com/open-webui/open-webui/issues/29244) +- ⌨️ **Preview shortcuts stay in the preview.** Arrow keys now page a document or slide preview only while that preview has focus, instead of paging it from anywhere on the page and taking the arrow keys away from whatever you were typing in. [Commit](https://github.com/open-webui/open-webui/commit/b356b80f8c86d110951301b83607de245b4ef6cc), [Commit](https://github.com/open-webui/open-webui/commit/e4694f82ebd2e916c099e5e921c0ca8445ba88a7) +- 🧊 **Streaming no longer stalls on reasoning models.** A reply from a reasoning model now streams through to the end instead of showing its first few words and then freezing until generation finishes. [#29053](https://github.com/open-webui/open-webui/pull/29053), [#29035](https://github.com/open-webui/open-webui/issues/29035) +- 💭 **Thinking stays in Thoughts.** After a model uses a tool, its reasoning for the next step now appears in the collapsed Thoughts section instead of being written into the reply as ordinary text. [#29052](https://github.com/open-webui/open-webui/pull/29052), [#29040](https://github.com/open-webui/open-webui/issues/29040) +- 🧠 **Large tool calls stream faster through Anthropic-compatible clients.** When a streamed tool call sends a large block of arguments, Open WebUI now waits until that block can actually be complete before checking it, instead of doing the same expensive check again on every tiny piece. [#28858](https://github.com/open-webui/open-webui/pull/28858) +- 🛑 **Stop works across instances.** Stopping a response now takes effect on deployments that spread people across several instances backed by a Redis cluster, instead of the reply continuing to the end regardless. [#29165](https://github.com/open-webui/open-webui/pull/29165), [#19840](https://github.com/open-webui/open-webui/issues/19840) +- 🎯 **Fewer needless chat reloads.** A conversation holding an older unfinished reply no longer reloads itself each time some other response in it finishes, reloading only for the reply the update actually concerns. [Commit](https://github.com/open-webui/open-webui/commit/22379ded1aebf0537e1ee3f00a20809d47646bf6) +- 📖 **Banners with underlined text.** A banner containing underlined text now displays instead of failing to render. [#29118](https://github.com/open-webui/open-webui/pull/29118), [#29115](https://github.com/open-webui/open-webui/issues/29115) +- 🧰 **Pinned models start with their own tools.** Starting a chat from a pinned model in the sidebar now applies that model's tools and skills instead of carrying over the ones from the model you used last. [#29058](https://github.com/open-webui/open-webui/pull/29058), [#29050](https://github.com/open-webui/open-webui/issues/29050) +- ❓ **Rejected ask_user calls no longer kill the reply.** If a model asks the user a question in a way Open WebUI refuses, the model now receives that error and can continue or retry, instead of leaving the chat stopped with no answer. [#29252](https://github.com/open-webui/open-webui/pull/29252), [#29077](https://github.com/open-webui/open-webui/issues/29077) +- 🚫 **Disabled models no longer vanish.** Turning a model off in the admin Models list now keeps it in view so you can turn it back on, instead of it disappearing with no way to recover it. [#29037](https://github.com/open-webui/open-webui/pull/29037), [#29036](https://github.com/open-webui/open-webui/issues/29036) +- ✂️ **Message text kept intact.** Text containing angle brackets and a dollar sign is no longer mistaken for a skill mention and silently removed before your message reaches the model. [#29051](https://github.com/open-webui/open-webui/pull/29051), [#29041](https://github.com/open-webui/open-webui/issues/29041) +- 📆 **Moved calendar events stay visible.** Changing an event's date no longer makes it disappear from the calendar, and events already stuck in that state show up again. [#29085](https://github.com/open-webui/open-webui/pull/29085), [#29067](https://github.com/open-webui/open-webui/issues/29067) +- 🔁 **Repeats follow the event.** A repeating event now works out its occurrences from its own date and time, where a repeat rule carrying a start date of its own could place them on the wrong weekday or at the wrong hour. [Commit](https://github.com/open-webui/open-webui/commit/a93c5080380447e3ab9113cacb73b61c57cbc308) +- 🔟 **Repeating automations keep their count.** An automation set to run a fixed number of times is no longer shown as running once and quietly rewritten to a single run the next time you open and save it. [#29261](https://github.com/open-webui/open-webui/pull/29261), [#29263](https://github.com/open-webui/open-webui/pull/29263) +- 🗓️ **Schedules survive a save.** An automation carrying a start date now shows its real schedule in the list and keeps its weekly or monthly setting when saved, instead of showing raw rule text and falling back to daily. [#29263](https://github.com/open-webui/open-webui/pull/29263) +- 🧩 **Custom automation schedules stay custom.** Reopening an automation with a custom repeat rule no longer loses the custom rule from the editor. [#29260](https://github.com/open-webui/open-webui/pull/29260) +- 🔢 **Accurate admin user counts.** The counts on the admin Users tabs now follow your search and reset when you switch tabs, instead of showing stale or unfiltered numbers. [#29080](https://github.com/open-webui/open-webui/pull/29080), [#29079](https://github.com/open-webui/open-webui/issues/29079) +- 🧷 **Sidebar folders stop acting stale.** A folder that has been removed from the sidebar is now removed from the internal sidebar registry too, so later sidebar actions do not target a folder that is no longer on screen. [#29121](https://github.com/open-webui/open-webui/pull/29121) +- 📱 **Readable model list on small screens.** The model identifier and timestamp in the workspace model list no longer overlap the model name on narrow displays. [#29084](https://github.com/open-webui/open-webui/pull/29084), [#29083](https://github.com/open-webui/open-webui/issues/29083) +- 🧱 **Long setting values stop crushing labels.** Settings rows now let long controls shrink inside the available space instead of squeezing the label beside them. [#29229](https://github.com/open-webui/open-webui/pull/29229) +- 🧭 **Model defaults panels stay usable.** The model capabilities and prompt suggestion sections in admin settings now scroll inside their panels instead of growing past the available space. [#29235](https://github.com/open-webui/open-webui/pull/29235) +- 📲 **Dropdowns stay on screen.** A dropdown near the edge of a narrow screen now shifts inward to stay fully visible instead of opening past the edge with its option labels cut off. [#29226](https://github.com/open-webui/open-webui/pull/29226), [#29225](https://github.com/open-webui/open-webui/issues/29225) +- 🌗 **Dropdown lists follow the theme.** The list a dropdown opens now takes its light or dark colouring from the rest of the interface instead of the system default. [Commit](https://github.com/open-webui/open-webui/commit/58a3fadbf336029733d6e5fdde05fd5a24e656fa) +- 🎛️ **Valves dialog stays in bounds.** A valve whose selected options form a long line no longer stretches its input past the edge of the dialog and over the page behind it. [#29203](https://github.com/open-webui/open-webui/pull/29203), [#29202](https://github.com/open-webui/open-webui/issues/29202) +- 🧹 **Shared chats search resets.** Reopening the shared chats dialog now starts with an empty search box and the full list, rather than a leftover search term above unfiltered results. [#29082](https://github.com/open-webui/open-webui/pull/29082), [#29081](https://github.com/open-webui/open-webui/issues/29081) +- 🔎 **Case-insensitive search beyond English.** Searching and filtering by tag now ignore letter case for accented and non-Latin text on installations backed by SQLite, where only unaccented English letters were matched whatever their case. [Commit](https://github.com/open-webui/open-webui/commit/26f37426b7ef6c0c3b1363f413ac9a83ccaaeb71) +- 🏷️ **Labels with colons and example URLs translate correctly.** Interface text such as `Warning:`, `http://localhost:8000`, and `https://mineru.net/api/v4` now appears correctly instead of being misread by the translation system. [#29161](https://github.com/open-webui/open-webui/pull/29161), [#29154](https://github.com/open-webui/open-webui/issues/29154) +- 🔑 **Precise account matching on SQLite.** Signing in through an identity provider now matches your account on its exact identifier, so accounts whose identifier is an unusually long or zero-padded number are no longer at risk of being confused with another. [Commit](https://github.com/open-webui/open-webui/commit/17cc566707cd2ee78b460e39c7fad23b9c258c70) +- 🗃️ **Knowledge list loads reliably.** The Knowledge page in the workspace now fills in its list on opening instead of occasionally staying empty. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e) +- 🎟️ **Tool servers without a key.** A tool server connection saved without a key is no longer sent an empty authorization header, which some servers refused outright. [Commit](https://github.com/open-webui/open-webui/commit/26f37426b7ef6c0c3b1363f413ac9a83ccaaeb71) +- 🐍 **Code interpreter starts on Windows.** The built-in code interpreter now serves its script and WebAssembly files with the browser-safe file types Windows hosts sometimes overwrite, so Pyodide can load instead of failing before code runs. [#29139](https://github.com/open-webui/open-webui/pull/29139), [#29133](https://github.com/open-webui/open-webui/issues/29133) + +### Changed + +- ⏱️ **Daily limit on repeats.** A calendar event can no longer be set to repeat more often than once a day, and saving one that repeats more often is now refused with a message explaining the limit. [Commit](https://github.com/open-webui/open-webui/commit/a93c5080380447e3ab9113cacb73b61c57cbc308) +- 🏷️ **High Contrast Mode is now Accessibility Mode.** The interface setting previously called 'High Contrast Mode' is now called 'Accessibility Mode', with the same switch in the same place. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e) + ## [0.11.1] - 2026-08-25 ### Added diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 3ce8d6cf26..c6c78de35a 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -3,7 +3,6 @@ from __future__ import annotations import errno from enum import Enum - _ERRNO_MESSAGES = { errno.ENAMETOOLONG: 'File name is too long.', errno.ENOSPC: 'The server is out of storage space.', @@ -121,6 +120,7 @@ class ERROR_MESSAGES(str, Enum): AUTOMATION_COUNT_REQUIRES_DTSTART = ( 'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window' ) + CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily' FEATURE_DISABLED = lambda name='': f'{name} is disabled' INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}' diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index aa96d2f2a0..3ad1ca553a 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import os +import re import sys from contextlib import asynccontextmanager, contextmanager from datetime import datetime, timedelta, timezone @@ -321,6 +322,45 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL: def _apply_sqlite_pragmas(dbapi_connection): """Apply all configured SQLite PRAGMAs to a raw DBAPI connection.""" + # SQLite LIKE folds ASCII only; SQLAlchemy SQLite ILIKE compiles to lower(x) LIKE lower(?). + compiled_patterns = {} + + def like(pattern, value, escape=None): + if pattern is None or value is None: + return None + + pattern = str(pattern).lower() + escape = str(escape).lower() if escape is not None else None + key = (pattern, escape) + compiled = compiled_patterns.get(key) + if compiled is False: + return False + if compiled is None: + regex = [] + escaped = False + for char in pattern: + if escape and not escaped and char == escape: + escaped = True + continue + regex.append( + '.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char) + ) + escaped = False + if escaped: + compiled = False + if len(compiled_patterns) >= 512: + compiled_patterns.clear() + compiled_patterns[key] = compiled + return False + compiled = re.compile(''.join(regex), re.DOTALL) + if len(compiled_patterns) >= 512: + compiled_patterns.clear() + compiled_patterns[key] = compiled + + return compiled.fullmatch(str(value).lower()) is not None + + dbapi_connection.create_function('like', 2, like, deterministic=True) + dbapi_connection.create_function('like', 3, like, deterministic=True) cursor = dbapi_connection.cursor() if DATABASE_ENABLE_SQLITE_WAL: cursor.execute('PRAGMA journal_mode=WAL') diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c84a394b87..67a14f16b2 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2980,6 +2980,11 @@ async def check_db_health(): # --- static assets & files --- +# Windows registry entries can override these with text/plain, which breaks module and wasm loading +mimetypes.add_type('text/javascript', '.js') +mimetypes.add_type('text/javascript', '.mjs') +mimetypes.add_type('application/wasm', '.wasm') + # Serve build-time static assets (CSS, JS, images, favicon, etc.) app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') @@ -3025,7 +3030,6 @@ def swagger_ui_html(*args, **kwargs): applications.get_swagger_ui_html = swagger_ui_html if os.path.exists(FRONTEND_BUILD_DIR): - mimetypes.add_type('text/javascript', '.js') pyodide_dir = FRONTEND_BUILD_DIR / 'pyodide' if os.path.exists(pyodide_dir): app.mount('/pyodide', CORSStaticFiles(directory=pyodide_dir), name='pyodide') diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 52470f1178..835313c703 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -4,10 +4,12 @@ from typing import Optional from uuid import uuid4 from open_webui.internal.db import Base, get_async_db_context +from open_webui.constants import ERROR_MESSAGES from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse -from pydantic import BaseModel, ConfigDict, Field +from open_webui.utils.automations import rrule_interval_seconds +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import ( JSON, BigInteger, @@ -26,6 +28,7 @@ from sqlalchemy import ( from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60 #################### @@ -191,6 +194,20 @@ class CalendarEventForm(BaseModel): meta: Optional[dict] = None attendees: Optional[list[dict]] = None + @field_validator('rrule') + @classmethod + def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]: + if value: + try: + interval = rrule_interval_seconds(value) + except ValueError: + raise + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) + if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS: + raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT) + return value + class CalendarEventUpdateForm(BaseModel): calendar_id: Optional[str] = None @@ -207,6 +224,20 @@ class CalendarEventUpdateForm(BaseModel): is_cancelled: Optional[bool] = None attendees: Optional[list[dict]] = None + @field_validator('rrule') + @classmethod + def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]: + if value: + try: + interval = rrule_interval_seconds(value) + except ValueError: + raise + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) + if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS: + raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT) + return value + class RSVPForm(BaseModel): status: str # 'accepted' | 'declined' | 'tentative' | 'pending' @@ -533,7 +564,8 @@ class CalendarEventTable: & (CalendarEvent.start_at < end) & or_( CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start), - CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start), + CalendarEvent.end_at.isnot(None) + & ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)), ) ), # Recurring: fetch all (expansion in Python) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index ae31aeda03..978cb2948f 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -362,7 +362,7 @@ class MessageStats(BaseModel): token_count: int | None = None timestamp: int | None = None rating: int | None = None # Derived from message.annotation.rating - tags: list[str | None] = None # Derived from message.annotation.tags + tags: list[str] | None = None # Derived from message.annotation.tags class ChatHistoryStats(BaseModel): @@ -2558,7 +2558,7 @@ class ChatTable: file_ids: list[str], user_id: str, db: AsyncSession | None = None, - ) -> list[ChatFileModel | None]: + ) -> list[ChatFileModel] | None: if not file_ids: return None diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 0f3b731ea4..17083e4b42 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -180,7 +180,7 @@ class ModelForm(BaseModel): name: str meta: ModelMeta params: ModelParams - access_grants: list[dict | None] = None + access_grants: list[dict] | None = None is_active: bool = True @@ -191,7 +191,7 @@ class ModelsTable: async def _to_model_model( self, model: Model, - access_grants: list[AccessGrantModel | None] = None, + access_grants: list[AccessGrantModel] | None = None, db: AsyncSession | None = None, ) -> ModelModel: if isinstance(model.meta, dict): diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 54e67ac2f4..e6e07eacc7 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -47,7 +47,7 @@ class PromptModel(BaseModel): content: str data: dict | None = None meta: dict | None = None - tags: list[str | None] = None + tags: list[str] | None = None is_active: bool | None = True version_id: str | None = None created_at: int | None = None @@ -86,8 +86,8 @@ class PromptForm(BaseModel): content: str data: dict | None = None meta: dict | None = None - tags: list[str | None] = None - access_grants: list[dict | None] = None + tags: list[str] | None = None + access_grants: list[dict] | None = None version_id: str | None = None # Active version commit_message: str | None = None # For history tracking is_production: bool | None = True # Whether to set new version as production @@ -100,7 +100,7 @@ class PromptsTable: async def _to_prompt_model( self, prompt: Prompt, - access_grants: list[AccessGrantModel | None] = None, + access_grants: list[AccessGrantModel] | None = None, db: AsyncSession | None = None, ) -> PromptModel: prompt_model = PromptModel.model_validate(prompt) @@ -334,8 +334,9 @@ class PromptsTable: tag_lower = tag.lower() if dialect_name == 'sqlite': + tag_lower = tag.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') tag_clause = text( - 'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)' + "EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE t.value LIKE :tag_val ESCAPE '\\')" ) elif dialect_name == 'postgresql': tag_clause = text( @@ -557,7 +558,7 @@ class PromptsTable: prompt_id: str, name: str, command: str, - tags: list[str | None] = None, + tags: list[str] | None = None, db: AsyncSession | None = None, ) -> PromptModel | None: """Update only name, command, and tags (no history created).""" diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 24e019aea9..ddb4d78581 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -89,7 +89,7 @@ class ToolForm(BaseModel): name: str content: str meta: ToolMeta - access_grants: list[dict | None] = None + access_grants: list[dict] | None = None class ToolValves(BaseModel): @@ -103,7 +103,7 @@ class ToolsTable: async def _to_tool_model( self, tool: Tool, - access_grants: list[AccessGrantModel | None] = None, + access_grants: list[AccessGrantModel] | None = None, db: AsyncSession | None = None, ) -> ToolModel: tool_model = ToolModel.model_validate(tool) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 3d132c529e..b7633759f5 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -367,7 +367,9 @@ class UsersTable: query = select(User).where(sub_expr == sub) # SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text. if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal(): - query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub))) + sub_int = int(sub) + if str(sub_int) == sub and sub_int <= 2**63 - 1: + query = select(User).where(or_(sub_expr == sub, sub_expr == sub_int)) row = (await session.execute(query)).scalars().first() return UserModel.model_validate(row) if row else None diff --git a/backend/open_webui/retrieval/web/brave.py b/backend/open_webui/retrieval/web/brave.py index f785cbe779..ac2ef3325f 100644 --- a/backend/open_webui/retrieval/web/brave.py +++ b/backend/open_webui/retrieval/web/brave.py @@ -16,7 +16,7 @@ async def search_brave( api_key: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, ) -> list[SearchResult]: """Query the Brave Web Search API and return normalised results. diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index 2d57b071cd..afd82eaf04 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) def search_duckduckgo( query: str, count: int, - filter_list: list[str | None] = None, + filter_list: list[str] | None = None, concurrent_requests: int | None = None, backend: str | None = 'auto', ) -> list[SearchResult]: diff --git a/backend/open_webui/retrieval/web/google_pse.py b/backend/open_webui/retrieval/web/google_pse.py index 3b5fe0d685..4cf6e18152 100644 --- a/backend/open_webui/retrieval/web/google_pse.py +++ b/backend/open_webui/retrieval/web/google_pse.py @@ -13,7 +13,7 @@ async def search_google_pse( search_engine_id: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, referer: str | None = None, ) -> list[SearchResult]: """Query Google Programmable Search Engine with automatic pagination. diff --git a/backend/open_webui/retrieval/web/microsoft_web_iq.py b/backend/open_webui/retrieval/web/microsoft_web_iq.py index 9d0df53e64..1e4de7442b 100644 --- a/backend/open_webui/retrieval/web/microsoft_web_iq.py +++ b/backend/open_webui/retrieval/web/microsoft_web_iq.py @@ -15,7 +15,7 @@ def search_microsoft_web_iq( api_key: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, language: str = 'en', user=None, ) -> list[SearchResult]: diff --git a/backend/open_webui/retrieval/web/mojeek.py b/backend/open_webui/retrieval/web/mojeek.py index da24c2a87c..1f216e7dd8 100644 --- a/backend/open_webui/retrieval/web/mojeek.py +++ b/backend/open_webui/retrieval/web/mojeek.py @@ -8,7 +8,7 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results log = logging.getLogger(__name__) -def search_mojeek(api_key: str, query: str, count: int, filter_list: list[str | None] = None) -> list[SearchResult]: +def search_mojeek(api_key: str, query: str, count: int, filter_list: list[str] | None = None) -> list[SearchResult]: """Search using Mojeek's Search API and return the results as a list of SearchResult objects. Args: diff --git a/backend/open_webui/retrieval/web/openserp.py b/backend/open_webui/retrieval/web/openserp.py index a9276cb1a0..547e43f724 100644 --- a/backend/open_webui/retrieval/web/openserp.py +++ b/backend/open_webui/retrieval/web/openserp.py @@ -12,7 +12,7 @@ async def search_openserp( base_url: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, ) -> list[SearchResult]: """Query an OpenSERP instance and return normalised results. diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index 9de56b28de..a4b3139f24 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -40,7 +40,7 @@ async def search_searxng( query_url: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, **kwargs, ) -> list[SearchResult]: """Query a SearXNG instance and return results sorted by relevance score. diff --git a/backend/open_webui/retrieval/web/serper.py b/backend/open_webui/retrieval/web/serper.py index 048e076dac..5ac168f171 100644 --- a/backend/open_webui/retrieval/web/serper.py +++ b/backend/open_webui/retrieval/web/serper.py @@ -13,7 +13,7 @@ async def search_serper( api_key: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, ) -> list[SearchResult]: """Query the serper.dev Google Search API and return normalised results. diff --git a/backend/open_webui/retrieval/web/serphouse.py b/backend/open_webui/retrieval/web/serphouse.py index 7b8ae59ac0..209fca4fb8 100644 --- a/backend/open_webui/retrieval/web/serphouse.py +++ b/backend/open_webui/retrieval/web/serphouse.py @@ -9,7 +9,7 @@ async def search_serphouse( domain: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, ) -> list[SearchResult]: """Query SERPHouse and return normalised organic results.""" session = await get_session() diff --git a/backend/open_webui/retrieval/web/serply.py b/backend/open_webui/retrieval/web/serply.py index 1daac461df..f22bba6e6f 100644 --- a/backend/open_webui/retrieval/web/serply.py +++ b/backend/open_webui/retrieval/web/serply.py @@ -17,7 +17,7 @@ def search_serply( limit: int = 10, device_type: str = 'desktop', proxy_location: str = 'US', - filter_list: list[str | None] = None, + filter_list: list[str] | None = None, ) -> list[SearchResult]: """Search using serper.dev's API and return the results as a list of SearchResult objects. diff --git a/backend/open_webui/retrieval/web/serpstack.py b/backend/open_webui/retrieval/web/serpstack.py index 532246c11a..ab7365fb9d 100644 --- a/backend/open_webui/retrieval/web/serpstack.py +++ b/backend/open_webui/retrieval/web/serpstack.py @@ -12,7 +12,7 @@ async def search_serpstack( api_key: str, query: str, count: int, - filter_list: list[str | None] | None = None, + filter_list: list[str] | None = None, https_enabled: bool = True, ) -> list[SearchResult]: """Query the serpstack.com API and return normalised results. diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index 73cf73152e..7bf15a1e62 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -13,7 +13,7 @@ def search_tavily( api_key: str, query: str, count: int, - filter_list: list[str | None] = None, + filter_list: list[str] | None = None, # **kwargs, ) -> list[SearchResult]: """Search using Tavily's Search API and return the results as a list of SearchResult objects. diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 0b20bc6a17..3c165f16ff 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1242,7 +1242,7 @@ async def post_new_message( except Exception as e: log.debug(e) - active_user_ids = get_user_ids_from_room(f'channel:{channel.id}') + active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}') # NOTE: We intentionally do NOT pass db to background_handler. # Background tasks should manage their own short-lived sessions to avoid diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index dd0cbf4f73..3e37f05eef 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -731,7 +731,7 @@ async def set_code_execution_config( class ModelsConfigForm(BaseModel): DEFAULT_MODELS: str | None DEFAULT_PINNED_MODELS: str | None - MODEL_ORDER_LIST: list[str | None] + MODEL_ORDER_LIST: list[str] | None DEFAULT_MODEL_METADATA: dict | None = None DEFAULT_MODEL_PARAMS: dict | None = None diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index a15dd91498..51570000c5 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -552,7 +552,12 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non ) url = request.app.url_path_for('get_file_content_by_id', id=file_item.id) - return file_item, url + return file_item, { + 'id': file_item.id, + 'url': url, + 'name': (file_item.meta or {}).get('name') or file_item.filename, + 'content_type': (file_item.meta or {}).get('content_type'), + } @router.post('/generations') @@ -668,8 +673,8 @@ async def image_generations( else: image_data, content_type = await get_image_data(image['b64_json']) - _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) - images.append({'url': url}) + _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append(image_file) return images elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': @@ -712,21 +717,21 @@ async def image_generations( if model.endswith(':predict'): for image in res['predictions']: image_data, content_type = await get_image_data(image['bytesBase64Encoded']) - _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) - images.append({'url': url}) + _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append(image_file) elif model.endswith(':generateContent'): for image in res['candidates']: for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): image_data, content_type = await get_image_data(part['inlineData']['data']) - _, url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, {**data, **metadata}, user, ) - images.append({'url': url}) + images.append(image_file) return images @@ -776,14 +781,14 @@ async def image_generations( headers, trusted_base_url=image_config.COMFYUI_BASE_URL, ) - _, url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, {**form_data.model_dump(exclude_none=True), **metadata}, user, ) - images.append({'url': url}) + images.append(image_file) return images elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': # Automatic1111 holds one checkpoint instance-wide, so set_image_model @@ -823,14 +828,14 @@ async def image_generations( for image in res['images']: image_data, content_type = await get_image_data(image) - _, url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, {**data, 'info': res['info'], **metadata}, user, ) - images.append({'url': url}) + images.append(image_file) return images except Exception as e: error = e @@ -1039,8 +1044,8 @@ async def image_edits( else: image_data, content_type = await get_image_data(image['b64_json']) - _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) - images.append({'url': url}) + _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append(image_file) return images elif image_config.IMAGE_EDIT_ENGINE == 'gemini': @@ -1089,14 +1094,14 @@ async def image_edits( for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): image_data, content_type = await get_image_data(part['inlineData']['data']) - _, url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, {**data, **metadata}, user, ) - images.append({'url': url}) + images.append(image_file) return images @@ -1173,14 +1178,14 @@ async def image_edits( headers, trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL, ) - _, url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, {**form_data.model_dump(exclude_none=True), **metadata}, user, ) - images.append({'url': url}) + images.append(image_file) return images except Exception as e: diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index d2864683e6..3b58c6f805 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1074,7 +1074,7 @@ async def update_external_knowledge_source( class KnowledgeFilesResponse(KnowledgeResponse): - files: list[FileMetadataResponse | None] = None + files: list[FileMetadataResponse] | None = None write_access: bool | None = False diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 00f41c01b3..e9a4685993 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -25,6 +25,7 @@ from open_webui.env import ( ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, + REDIS_KEY_PREFIX, ) from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.internal.db import get_async_session @@ -56,6 +57,7 @@ log = logging.getLogger(__name__) # in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462. _STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'}) _MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) +BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' def _clean_proxy_headers(raw_headers) -> dict: @@ -319,6 +321,9 @@ async def update_config( ) await get_all_models.cache.clear() + redis = getattr(request.app.state, 'redis', None) + if redis is not None: + await redis.delete(BASE_MODELS_CACHE_KEY) request.app.state.BASE_MODELS = [] request.app.state.OLLAMA_MODELS = {} models = getattr(request.app.state, 'MODELS', None) @@ -970,12 +975,12 @@ class GenerateCompletionForm(BaseModel): model: str prompt: str | None = None suffix: str | None = None - images: list[str | None] = None + images: list[str] | None = None format: Union[dict, str | None] = None options: dict | None = None system: str | None = None template: str | None = None - context: list[int | None] = None + context: list[int] | None = None stream: bool | None = True raw: bool | None = None keep_alive: Union[int, str | None] = None @@ -1026,8 +1031,8 @@ class ChatMessage(BaseModel): role: str content: str | None = None - tool_calls: list[dict | None] = None - images: list[str | None] = None + tool_calls: list[dict] | None = None + images: list[str] | None = None model_config = ConfigDict(extra='allow') @validator('content', pre=True) @@ -1048,7 +1053,7 @@ class GenerateChatCompletionForm(BaseModel): template: str | None = None stream: bool | None = True keep_alive: Union[int, str | None] = None - tools: list[dict | None] = None + tools: list[dict] | None = None model_config = ConfigDict(extra='allow') diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 0f21defdd5..d7856bea48 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -30,6 +30,7 @@ from open_webui.env import ( ENABLE_OPENAI_API_PASSTHROUGH, FORWARD_SESSION_INFO_HEADER_CHAT_ID, MODELS_CACHE_TTL, + REDIS_KEY_PREFIX, ) from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed from open_webui.internal.db import get_async_session @@ -76,6 +77,7 @@ log = logging.getLogger(__name__) _STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'}) _MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) _UNSUPPORTED_OPENAI_MODEL_KEYWORDS = ('babbage', 'dall-e', 'davinci', 'embedding', 'tts', 'whisper') +BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' def _clean_proxy_headers(raw_headers) -> dict: @@ -345,6 +347,9 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]: async def clear_openai_model_cache(request: Request): await get_all_models.cache.clear() + redis = getattr(request.app.state, 'redis', None) + if redis is not None: + await redis.delete(BASE_MODELS_CACHE_KEY) request.app.state.BASE_MODELS = [] request.app.state.OPENAI_MODELS = {} models = getattr(request.app.state, 'MODELS', None) @@ -571,14 +576,7 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe } ) - await get_all_models.cache.clear() - request.app.state.BASE_MODELS = [] - request.app.state.OPENAI_MODELS = {} - models = getattr(request.app.state, 'MODELS', None) - if hasattr(models, 'clear'): - models.clear() - else: - request.app.state.MODELS = {} + await clear_openai_model_cache(request) await publish_event( request, diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 827fadf7c9..753c9fc77f 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -69,6 +69,9 @@ async def process_pipeline_inlet_filter(request, payload, user, models): if 'pipeline' in model: sorted_filters.append(model) + if not sorted_filters: + return payload + async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: urlIdx = filter.get('urlIdx') @@ -132,6 +135,9 @@ async def process_pipeline_outlet_filter(request, payload, user, models): if 'pipeline' in model: sorted_filters = [model] + sorted_filters + if not sorted_filters: + return payload + async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: urlIdx = filter.get('urlIdx') diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 0e3c9c6ee4..9e90a404b0 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -36,7 +36,7 @@ class PromptVersionUpdateForm(BaseModel): class PromptMetadataForm(BaseModel): name: str command: str - tags: list[str | None] = None + tags: list[str] | None = None router = APIRouter() diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 517565f12f..6833d491db 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -790,7 +790,7 @@ class WebConfig(BaseModel): WEB_SEARCH_TRUST_ENV: bool | None = None WEB_SEARCH_RESULT_COUNT: int | None = None WEB_SEARCH_CONCURRENT_REQUESTS: int | None = None - WEB_SEARCH_DOMAIN_FILTER_LIST: list[str | None] = [] + WEB_SEARCH_DOMAIN_FILTER_LIST: list[str] | None = [] WEB_FETCH_MAX_CONTENT_LENGTH: int | None = None WEB_LOADER_CONCURRENT_REQUESTS: int | None = None BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: bool | None = None @@ -848,7 +848,7 @@ class WebConfig(BaseModel): EXTERNAL_WEB_SEARCH_API_KEY: str | None = None EXTERNAL_WEB_LOADER_URL: str | None = None EXTERNAL_WEB_LOADER_API_KEY: str | None = None - YOUTUBE_LOADER_LANGUAGE: list[str | None] = None + YOUTUBE_LOADER_LANGUAGE: list[str] | None = None YOUTUBE_LOADER_PROXY_URL: str | None = None YOUTUBE_LOADER_TRANSLATION: str | None = None YANDEX_WEB_SEARCH_URL: str | None = None @@ -938,7 +938,7 @@ class ConfigForm(BaseModel): FILE_MAX_COUNT: Union[int, str | None] = None FILE_IMAGE_COMPRESSION_WIDTH: Union[int, str | None] = None FILE_IMAGE_COMPRESSION_HEIGHT: Union[int, str | None] = None - ALLOWED_FILE_EXTENSIONS: list[str | None] = None + ALLOWED_FILE_EXTENSIONS: list[str] | None = None # Integration settings ENABLE_GOOGLE_DRIVE_INTEGRATION: bool | None = None diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 0998682dd8..c3678f592d 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -5,6 +5,7 @@ import logging import random import sys import time +from typing import Any import pycrdt as Y import socketio @@ -47,6 +48,7 @@ from open_webui.utils.redis import ( get_redis_connection, get_sentinels_from_env, ) +from socketio.packet import Packet logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -65,6 +67,17 @@ def get_room_sid_map(manager, namespace: str, room: str): return manager.rooms.get(namespace, {}).get(room) +class JSONOnlyPacket(Packet): + """Packet class for JSON-serializable payloads only, skipping python-socketio's per-emit binary scan.""" + + uses_binary_events = False + + @classmethod + def reconstruct_binary(cls, data: Any, attachments: list[bytes]): + """Normalize client attachments to int lists, the form the Yjs handlers store and apply.""" + return super().reconstruct_binary(data, [list(attachment) for attachment in attachments]) + + if WEBSOCKET_MANAGER == 'redis': sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or '' ws_redis_url = ( @@ -77,6 +90,7 @@ if WEBSOCKET_MANAGER == 'redis': cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode='asgi', json=SOCKETIO_JSON, + serializer=JSONOnlyPacket, transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, always_connect=True, @@ -91,6 +105,7 @@ else: cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode='asgi', json=SOCKETIO_JSON, + serializer=JSONOnlyPacket, transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, always_connect=True, @@ -175,6 +190,13 @@ YDOC_MANAGER = YdocManager( ) +def get_session_pool_batches(): + """All session pool entries, in bounded batches for the Redis backing.""" + if WEBSOCKET_MANAGER == 'redis': + return SESSION_POOL.scan_batches() + return [list(SESSION_POOL.items())] + + async def periodic_session_pool_cleanup(): """Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance).""" retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) @@ -192,14 +214,20 @@ async def periodic_session_pool_cleanup(): break now = int(time.time()) - for sid in list(SESSION_POOL.keys()): - entry = SESSION_POOL.get(sid) - if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT: - log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})') - try: - del SESSION_POOL[sid] - except KeyError: - pass + for batch in get_session_pool_batches(): + expired = [ + sid + for sid, entry in batch + if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT + ] + if expired: + log.warning('Reaping %d orphaned session(s) from the session pool', len(expired)) + if WEBSOCKET_MANAGER == 'redis': + SESSION_POOL.delete_many(*expired) + else: + for sid in expired: + SESSION_POOL.pop(sid, None) + await asyncio.sleep(0) # don't hold the loop for the whole sweep next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT lock_lost = False @@ -283,6 +311,14 @@ def get_user_id_from_session_pool(sid): return None +async def get_socket_session_user(sid: str) -> dict | None: + """Session user from this worker's local Socket.IO store; only locally connected sids are ever looked up.""" + try: + return (await sio.get_session(sid)).get('user') + except KeyError: + return None + + def get_session_ids_from_room(room): """Get all session IDs from a specific room.""" members = get_room_sid_map(sio.manager, '/', room) @@ -296,19 +332,9 @@ def get_session_ids_by_user_id(user_id: str) -> list[str]: return list(session_ids) -def get_user_ids_from_room(room): - active_session_ids = get_session_ids_from_room(room) - - # Single pool lookup per session (each .get is a Redis round trip - # when the session pool is Redis-backed). - active_user_ids = list( - { - entry['id'] - for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids) - if entry is not None - } - ) - return active_user_ids +async def get_user_ids_from_room(room) -> set[str]: + users = [await get_socket_session_user(session_id) for session_id in get_session_ids_from_room(room)] + return {user['id'] for user in users if user} async def emit_to_users(event: str, data: dict, user_ids: list[str]): @@ -364,7 +390,7 @@ async def disconnect_user_sessions(user_id: str): @sio.on('usage') async def usage(sid, data): - if sid in SESSION_POOL: + if await get_socket_session_user(sid): model_id = data['model'] # Record the timestamp for the last update current_time = int(time.time()) @@ -446,7 +472,7 @@ async def user_join(sid, data): @sio.on('heartbeat') async def heartbeat(sid, data): - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if user: SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())} await Users.update_last_active_by_id(user['id']) @@ -519,7 +545,7 @@ async def channel_events(sid, data): event_data = data['data'] event_type = event_data['type'] - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if not user: return @@ -559,15 +585,7 @@ async def get_folder_unread_counts(user_id: str) -> dict[str, int]: @sio.on('events:chat') async def chat_events(sid, data): - try: - session = await sio.get_session(sid) - user = session.get('user') - except KeyError: - user = None - - if not user: - user = SESSION_POOL.get(sid) - + user = await get_socket_session_user(sid) if not user: return @@ -621,7 +639,7 @@ def normalize_document_id(document_id: str) -> str: @sio.on('ydoc:document:join') async def ydoc_document_join(sid, data): """Handle user joining a document""" - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if not user: return @@ -781,7 +799,7 @@ async def yjs_document_update(sid, data): return # Verify write permission — room membership only proves read access - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if not user: return @@ -851,7 +869,7 @@ async def yjs_document_update(sid, data): @sio.on('ydoc:document:leave') async def yjs_document_leave(sid, data): """Handle user leaving a document""" - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if not user: # authenticated session required (parity with sibling handlers) return try: @@ -883,7 +901,7 @@ async def yjs_document_leave(sid, data): @sio.on('ydoc:awareness:update') async def yjs_awareness_update(sid, data): """Handle awareness updates (cursors, selections, etc.)""" - user = SESSION_POOL.get(sid) + user = await get_socket_session_user(sid) if not user: # authenticated session required (parity with sibling handlers) return try: @@ -911,9 +929,8 @@ async def disconnect(sid, reason=None): del SESSION_POOL[sid] # Clean up USAGE_POOL entries for this session - for model_id in list(USAGE_POOL.keys()): - connections = USAGE_POOL.get(model_id) - if connections and sid in connections: + for model_id, connections in list(USAGE_POOL.items()): + if sid in connections: del connections[sid] if not connections: del USAGE_POOL[model_id] @@ -938,20 +955,26 @@ async def _make_channel_emitter(request_info): state = {'last_emit_at': 0.0, 'output': []} THROTTLE_INTERVAL = 0.15 # ~6 updates/sec - async def _emit_channel_update(content: str, done: bool = False, output: list | None = None): + async def _emit_channel_update( + content: str, + done: bool = False, + output: list | None = None, + data: dict | None = None, + ): from open_webui.models.messages import MessageForm, Messages msg = await Messages.get_message_by_id(message_id) if not msg or msg.channel_id != channel_id: return - update_form = MessageForm(content=content, data={'output': output} if output else None) + update_data = data or ({'output': output} if output else None) + update_form = MessageForm(content=content, data=update_data) if done: # Merge done flag into existing meta (preserve model_id etc.) existing_meta = msg.meta or {} update_form = MessageForm( content=content, - data={'output': output} if output else None, + data=update_data, meta={**existing_meta, 'done': True}, ) @@ -1000,6 +1023,29 @@ async def _make_channel_emitter(request_info): state['last_emit_at'] = now await _emit_channel_update(content, False, state['output']) + elif event_type in ('files', 'chat:message:files'): + from open_webui.models.messages import Messages + + files = event_data.get('data', {}).get('files', []) + if not files: + return + + msg = await Messages.get_message_by_id(message_id) + if not msg or msg.channel_id != channel_id: + return + + existing_files = (msg.data or {}).get('files') + for file in files: + if isinstance(file, dict) and file.get('id'): + file['url'] = file['id'] + await Channels.add_file_to_channel_by_id(channel_id, file['id'], msg.user_id) + await Channels.set_file_message_id_in_channel_by_id(channel_id, file['id'], message_id) + + if isinstance(existing_files, list): + files.extend(existing_files) + + await _emit_channel_update(msg.content, data={'files': files}) + elif event_type == 'chat:message:error': error = event_data.get('data', {}).get('error', {}) error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error) diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 09fa47de15..97df5d4c38 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -11,6 +11,7 @@ from open_webui.utils.json_codec import JSONCodec from open_webui.utils.redis import get_redis_connection YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents' +SCAN_BATCH_SIZE = 200 class RedisLock: @@ -112,6 +113,22 @@ class RedisDict: def items(self): return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()] + def scan_batches(self): + """Yield lists of (key, value) pairs via incremental HSCAN; a field may repeat across batches.""" + cursor = 0 + while True: + cursor, batch = self.redis.hscan(self.name, cursor, count=SCAN_BATCH_SIZE) + if batch: + yield [(k, JSONCodec.loads(v)) for k, v in batch.items()] + if cursor == 0: + break + + def delete_many(self, *keys): + """Delete fields in one HDEL; no keys is a no-op (HDEL rejects an empty field list).""" + if keys: + self.redis.hdel(self.name, *keys) + self._last_signature = None + def set(self, mapping: dict): if not mapping: self.clear() diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index ef15c57a18..0eb223e9b6 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -32,6 +32,9 @@ async def redis_task_command_listener(app): while True: pubsub = None try: + # RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache. + await redis.initialize() + pubsub = redis.pubsub() await pubsub.subscribe(REDIS_PUBSUB_CHANNEL) reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index e886dad315..853aaaa9d5 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -389,11 +389,16 @@ async def generate_image( images = await image_generations( request=__request__, form_data=CreateImageForm(prompt=prompt), + metadata=( + {'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__} + if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:') + else None + ), user=user, ) # Prepare file entries for the images - image_files = [{'type': 'image', 'url': img['url']} for img in images] + image_files = [{'type': 'image', **img} for img in images] # Persist files to DB if chat context is available if is_saved_chat_id(__chat_id__) and __message_id__ and images: @@ -457,11 +462,16 @@ async def edit_image( images = await image_edits( request=__request__, form_data=EditImageForm(prompt=prompt, image=image_urls), + metadata=( + {'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__} + if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:') + else None + ), user=user, ) # Prepare file entries for the images - image_files = [{'type': 'image', 'url': img['url']} for img in images] + image_files = [{'type': 'image', **img} for img in images] # Persist files to DB if chat context is available if is_saved_chat_id(__chat_id__) and __message_id__ and images: diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 3d49643cec..b6672af48f 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -877,7 +877,11 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode() # Close the block once arguments form complete JSON - if tool['started'] and not tool['stopped']: + if ( + tool['started'] + and not tool['stopped'] + and (tool['arguments'].rstrip()[-1:] == '}' or tool['arguments'].lstrip()[:1] != '{') + ): try: JSONCodec.loads(tool['arguments']) tool['stopped'] = True diff --git a/backend/open_webui/utils/ask_user.py b/backend/open_webui/utils/ask_user.py index 6c4168d2d7..14f5da6ad0 100644 --- a/backend/open_webui/utils/ask_user.py +++ b/backend/open_webui/utils/ask_user.py @@ -6,17 +6,20 @@ from open_webui.utils.json_codec import JSONCodec ASK_USER_NAME = 'ask_user' -def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]: +def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | None]: ask_user_calls = [ tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME ] if not ask_user_calls: - return None, None + return [], None if len(tool_calls) != 1: - return ask_user_calls[0], 'Error: ask_user must be called by itself after research.' + return ( + ask_user_calls, + 'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.', + ) if len(ask_user_calls) != 1: - return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.' - return ask_user_calls[0], None + return ask_user_calls, 'Error: only one ask_user call is allowed per turn.' + return ask_user_calls, None def normalize_ask_user_request(arguments: dict) -> dict: @@ -77,68 +80,70 @@ def normalize_ask_user_request(arguments: dict) -> dict: } -def stage_ask_user_tool_call( +def stage_ask_user_tool_calls( tool_calls: list[dict], output: list[dict], make_output_id: Callable[[str], str], -) -> dict | None: - tool_call, error = get_ask_user_tool_call(tool_calls) - if not tool_call: - return None +) -> tuple[bool, str | None]: + ask_user_calls, error = get_ask_user_tool_calls(tool_calls) + if not ask_user_calls: + return False, None - call_id = tool_call.get('id') or make_output_id('fc') - raw_arguments = tool_call.get('function', {}).get('arguments', '{}') - arguments = raw_arguments + for tool_call in ask_user_calls: + call_id = tool_call.get('id') or make_output_id('fc') + raw_arguments = tool_call.get('function', {}).get('arguments', '{}') + arguments = raw_arguments - if not error: - try: - parsed_arguments = JSONCodec.loads(raw_arguments or '{}') - if not isinstance(parsed_arguments, dict): - raise ValueError('ask_user arguments must be an object.') - arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments)) - except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc: - error = f'Error: {exc}' + if not error: + try: + parsed_arguments = JSONCodec.loads(raw_arguments or '{}') + if not isinstance(parsed_arguments, dict): + raise ValueError('ask_user arguments must be an object.') + arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments)) + except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc: + error = f'Error: {exc}' - item = { - 'type': 'function_call', - 'id': call_id or make_output_id('fc'), - 'call_id': call_id, - 'name': ASK_USER_NAME, - 'arguments': arguments, - 'status': 'completed' if error else 'pending', - } + item = { + 'type': 'function_call', + 'id': call_id or make_output_id('fc'), + 'call_id': call_id, + 'name': ASK_USER_NAME, + 'arguments': arguments, + 'status': 'completed' if error else 'pending', + } - existing_item = next( - ( - existing - for existing in output - if existing.get('type') == 'function_call' - and ( - existing.get('call_id') == call_id - or existing.get('id') == tool_call.get('id') - or ( - not existing.get('call_id') - and existing.get('name') == ASK_USER_NAME - and existing.get('status') not in {'rejected', 'failed'} + existing_item = next( + ( + existing + for existing in output + if existing.get('type') == 'function_call' + and ( + existing.get('call_id') == call_id + or existing.get('id') == tool_call.get('id') + or ( + not existing.get('call_id') + and existing.get('name') == ASK_USER_NAME + and existing.get('status') not in {'rejected', 'failed'} + ) ) - ) - ), - None, - ) - if existing_item: - existing_item.update(item) - else: - output.append(item) - - if error: - output.append( - { - 'type': 'function_call_output', - 'id': make_output_id('fco'), - 'call_id': call_id, - 'output': [{'type': 'input_text', 'text': error}], - 'status': 'completed', - } + ), + None, ) + if existing_item: + existing_item.update(item) + else: + output.append(item) - return {'call_id': call_id, 'error': error, 'item': item} + # Every invalid call needs its own result, or the UI waits on it forever. + if error: + output.append( + { + 'type': 'function_call_output', + 'id': make_output_id('fco'), + 'call_id': call_id, + 'output': [{'type': 'input_text', 'text': error}], + 'status': 'completed', + } + ) + + return True, error diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 3ff9910e22..d4c62b58d6 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -185,6 +185,7 @@ def rrule_interval_seconds(s: str) -> Optional[int]: """ if 'COUNT=1' in s: return None + s = '\n'.join(line for line in s.splitlines() if not line.upper().startswith('DTSTART')) or s now = datetime.now() rule = _parse_rule(s, now) first = rule.after(now) diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py index 16d0f2b659..1ce590203a 100644 --- a/backend/open_webui/utils/calendar.py +++ b/backend/open_webui/utils/calendar.py @@ -8,6 +8,7 @@ import datetime as dt import logging from zoneinfo import ZoneInfo +from dateutil.rrule import rrulestr from open_webui.utils.automations import _resolve_tz log = logging.getLogger(__name__) @@ -25,18 +26,17 @@ def expand_recurring_event( Takes an event dict (from CalendarEventModel.model_dump()) and produces one dict per occurrence, with adjusted start_at / end_at. """ - from dateutil.rrule import rrulestr - rrule_str = event_dict.get('rrule') if not rrule_str: return [event_dict] + if 'EXRULE' in rrule_str.upper(): + log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}') + return [event_dict] user_timezone = _resolve_tz(tz) def to_local_datetime(timestamp_ns: int) -> dt.datetime: - if user_timezone: - return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None) - return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000) + return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None) range_start = to_local_datetime(range_start_ns) range_end = to_local_datetime(range_end_ns) @@ -44,10 +44,11 @@ def expand_recurring_event( original_start_ns = event_dict['start_at'] original_start = to_local_datetime(original_start_ns) + rule_str = '\n'.join(line for line in rrule_str.splitlines() if not line.upper().startswith('DTSTART')) or rrule_str try: # Anchor to the event's real start so day-of-week / day-of-month are correct - rule = rrulestr(rrule_str, dtstart=original_start, ignoretz=True) + rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True) except Exception: log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') return [event_dict] @@ -56,13 +57,13 @@ def expand_recurring_event( duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None instances = [] - occurrence_start = rule.after(scan_start, inc=True) + previous_start = None + for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True): + if occurrence_start >= range_end or occurrence_start == previous_start: + break + previous_start = occurrence_start - while occurrence_start and occurrence_start < range_end and len(instances) < max_instances: - if user_timezone: - instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) - else: - instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000) + instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) if instance_start_ns >= range_start_ns: instance = { @@ -73,8 +74,6 @@ def expand_recurring_event( } instances.append(instance) - occurrence_start = rule.after(occurrence_start) - return instances diff --git a/backend/open_webui/utils/chat_variables.py b/backend/open_webui/utils/chat_variables.py index 5ff2e21b05..c1776f333f 100644 --- a/backend/open_webui/utils/chat_variables.py +++ b/backend/open_webui/utils/chat_variables.py @@ -112,7 +112,7 @@ def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]: 'type', } field = {'key': key} - for field_key in allowed_keys: + for field_key in sorted(allowed_keys): if field_key in definition: field[field_key] = definition[field_key] diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 74527a40b6..676af24afe 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -54,6 +54,16 @@ _IMAGE_MIME_FALLBACK = { async def get_image_base64_from_url(url: str, user=None) -> Optional[str]: try: if url.startswith('http'): + from open_webui.models.config import Config + + max_bytes = None + try: + max_size_mb = int(await Config.get('rag.file.max_size') or 0) + except (TypeError, ValueError): + max_size_mb = 0 + if max_size_mb > 0: + max_bytes = max_size_mb * 1024 * 1024 + # Validate URL to prevent SSRF attacks against local/private networks. # allow_redirects=False prevents redirect-based SSRF: validate_url() is # called only on the originally-submitted URL; following 3xx redirects @@ -67,7 +77,13 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]: url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS ) as response: response.raise_for_status() - image_data = await response.read() + image_data = bytearray() + total = 0 + async for chunk in response.content.iter_chunked(64 * 1024): + total += len(chunk) + if max_bytes is not None and total > max_bytes: + return None + image_data.extend(chunk) encoded_string = base64.b64encode(image_data).decode('utf-8') content_type = response.headers.get('Content-Type', 'image/png') return f'data:{content_type};base64,{encoded_string}' @@ -86,13 +102,14 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user # Extract base64 image data from the line image_data, content_type = await get_image_data(base64_image_string) if image_data is not None: - _, image_url = await upload_image( + _, image_file = await upload_image( request, image_data, content_type, metadata, user, ) + image_url = image_file['url'] return image_url return None diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 56be906288..c4d9754e39 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -10,10 +10,16 @@ log = logging.getLogger(__name__) class FilterContext: def __init__(self): + self.active_filters = None self.valves_by_id = None self.function_valves = {} self.user_valves = {} + async def get_active_filters(self): + if self.active_filters is None: + self.active_filters = await Functions.get_active_filter_ids() + return self.active_filters + async def get_function_valves(self, filter_ids, filter_id, Valves): if filter_id not in self.function_valves: if self.valves_by_id is None: @@ -29,6 +35,12 @@ class FilterContext: return self.user_valves[user_valves_key] +def get_filter_context(request): + if not hasattr(request.state, 'filter_context'): + request.state.filter_context = FilterContext() + return request.state.filter_context + + async def get_user_valves(filter_id, user_id, UserValves): user_valves_data = await Functions.get_user_valves_by_id_and_user_id(filter_id, user_id) return UserValves(**(user_valves_data if user_valves_data else {})) @@ -57,7 +69,7 @@ async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list if not ENABLE_PLUGINS: return [], [] - active_filters = await Functions.get_active_filter_ids() + active_filters = await get_filter_context(request).get_active_filters() filter_ids = get_model_filter_ids(model, active_filters) functions_by_id = {function.id: function for function in await Functions.get_functions_by_ids(filter_ids)} @@ -166,7 +178,7 @@ async def process_filter_function( request, filter_id, load_from_db=(filter_type != 'stream'), function=function ) handler = getattr(function_module, filter_type, None) - if not handler: + if not callable(handler): return form_data, valves_by_id, None skip_files = ( diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index cc5f57e0c3..e6c561a8ca 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -81,7 +81,7 @@ from open_webui.tasks import clear_response_stream, save_response_stream from open_webui.utils.access_control import has_connection_access, has_permission from open_webui.utils.access_control.files import get_owner_accessible_folder_files from open_webui.utils.access_control.folders import has_folder_access -from open_webui.utils.ask_user import stage_ask_user_tool_call +from open_webui.utils.ask_user import stage_ask_user_tool_calls from open_webui.utils.chat import generate_chat_completion from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.code_interpreter import execute_code_jupyter @@ -94,6 +94,7 @@ from open_webui.utils.files import ( ) from open_webui.utils.filter import ( FilterContext, + get_filter_context, get_filter_functions, process_filter_functions, ) @@ -186,6 +187,12 @@ def _is_tool_result_error(value: Any) -> bool: return False +def normalize_messages_for_model(form_data: dict) -> dict: + form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', [])) + form_data['messages'] = merge_system_messages(form_data.get('messages', [])) + return form_data + + async def publish_chat_finished_event( request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None ): @@ -258,12 +265,7 @@ def build_terminal_file_tool_result( if isinstance(tool_result, (list, tuple)) and tool_result and isinstance(tool_result[0], dict): tool_result = tool_result[0] - if ( - tool_function_name != 'display_file' - or tool_function_params.get('inline') is not True - or not isinstance(tool_result, dict) - or tool_result.get('exists') is False - ): + if tool_function_name != 'display_file' or not isinstance(tool_result, dict) or tool_result.get('exists') is False: return None tool_id = (tool or {}).get('tool_id', '') @@ -284,7 +286,7 @@ def build_terminal_file_tool_result( **tool_result, 'type': 'file', 'source': 'open_terminal', - 'displayed': True, + **({'displayed': True} if tool_function_params.get('inline') is True else {}), 'terminal_selector': terminal_selector, **({'terminal_id': terminal_id} if terminal_id else {}), **({'terminal_url': server_url} if server_url and not terminal_id else {}), @@ -1771,6 +1773,12 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: return form_data + is_channel_chat = chat_id.startswith('channel:') + image_metadata = { + 'message_id': metadata.get('message_id', None), + **({'channel_id': chat_id.removeprefix('channel:')} if is_channel_chat else {'chat_id': chat_id}), + } + if not is_saved_chat_id(chat_id): message_list = form_data.get('messages', []) else: @@ -1815,10 +1823,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra images = await image_edits( request=request, form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}), - metadata={ - 'chat_id': metadata.get('chat_id', None), - 'message_id': metadata.get('message_id', None), - }, + metadata=image_metadata, user=user, ) @@ -1836,7 +1841,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra 'files': [ { 'type': 'image', - 'url': image['url'], + **image, } for image in images ] @@ -1926,10 +1931,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra images = await image_generations( request=request, form_data=CreateImageForm(**{'prompt': prompt}), - metadata={ - 'chat_id': metadata.get('chat_id', None), - 'message_id': metadata.get('message_id', None), - }, + metadata=image_metadata, user=user, ) @@ -1947,7 +1949,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra 'files': [ { 'type': 'image', - 'url': image['url'], + **image, } for image in images ] @@ -2265,7 +2267,8 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: return sanitized -SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>') +# Ids are validated as [a-z0-9_-]+ on create; matching that keeps ordinary "<$..." text intact. +SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>') def _get_text_parts(message: dict) -> list[str]: @@ -2287,7 +2290,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: return ids -SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>') +SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[a-z0-9_-]+(?:\|([^>]*))?|/[a-z0-9_-]+\|([^>]*))>') def strip_skill_mentions(messages: list[dict]) -> None: @@ -2397,7 +2400,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['model'] = selected_model_id metadata['selected_model_id'] = selected_model_id - # Captured before apply_params_to_form_data pops 'params'; feeds metadata['system_prompt'] below + # Captured before apply_params_to_form_data pops 'params'; populates metadata['system_prompt'] below model_system_prompt = (form_data.get('params') or {}).get('system') form_data = apply_params_to_form_data(form_data, model) @@ -2623,13 +2626,15 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception as e: raise e + filter_functions = [] + filter_context = get_filter_context(request) if ENABLE_PLUGINS else None if ENABLE_PLUGINS: try: filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', [])) form_data, flags = await process_filter_functions( request=request, - filter_context=None, + filter_context=filter_context, filter_functions=filter_functions, filter_type='inlet', form_data=form_data, @@ -3094,13 +3099,20 @@ async def process_chat_payload(request, form_data, user, metadata, model): } ) - # Strip empty text content blocks from multimodal messages - # to prevent errors from providers like Gemini and Claude - form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', [])) + if ENABLE_PLUGINS: + try: + form_data, _ = await process_filter_functions( + request=request, + filter_context=filter_context, + filter_functions=filter_functions, + filter_type='request', + form_data=form_data, + extra_params=extra_params, + ) + except Exception as e: + raise Exception(f'{e}') - # 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', [])) + form_data = normalize_messages_for_model(form_data) return form_data, metadata, events @@ -3225,10 +3237,12 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool: chat_id = metadata.get('chat_id') - message_id = metadata.get('message_id') or metadata.get('assistant_message_id') - if not is_saved_chat_id(chat_id) or not message_id: + assistant_message_id = metadata.get('assistant_message_id') + # Only a resume/continue payload re-enters an existing message; other paths mint a fresh id with nothing to drain. + if not is_saved_chat_id(chat_id) or not assistant_message_id: return False + message_id = metadata.get('message_id') or assistant_message_id message = await Chats.get_message_by_id_and_message_id(chat_id, message_id) output = message.get('output') if message else None if not isinstance(output, list): @@ -3388,6 +3402,34 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - ) form_data['messages'] = sanitize_tool_pairs(form_data['messages']) + if not paused and ENABLE_PLUGINS: + filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', [])) + if filter_functions: + filtered_form_data, _ = await process_filter_functions( + request=request, + filter_context=get_filter_context(request), + filter_functions=filter_functions, + filter_type='request', + form_data=form_data, + extra_params={ + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': await get_system_oauth_token(request, user), + '__request__': request, + '__model__': model, + '__chat_id__': metadata.get('chat_id'), + '__message_id__': metadata.get('message_id'), + }, + ) + if filtered_form_data is not form_data: + form_data.clear() + form_data.update(filtered_form_data) + + if not paused: + normalize_messages_for_model(form_data) + return paused return False @@ -4196,6 +4238,8 @@ async def streaming_chat_response_handler(response, ctx): '__oauth_token__': await get_system_oauth_token(request, user), '__request__': request, '__model__': model, + '__chat_id__': metadata.get('chat_id'), + '__message_id__': metadata.get('message_id'), } filter_functions = ( @@ -5222,7 +5266,12 @@ async def streaming_chat_response_handler(response, ctx): reasoning_detail_items, ) await save_current_response_stream() - data = None + # Providers such as OpenRouter send reasoning_details + # alongside the reasoning text: only drop the event when + # the details were all there was to report, otherwise the + # reasoning delta never reaches the client. + if not reasoning_content: + data = None if value: if ( @@ -5530,12 +5579,14 @@ async def streaming_chat_response_handler(response, ctx): tool_call_iterations += 1 response_tool_calls = tool_calls.pop(0) - ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id) - if ask_user_stage: - if ask_user_stage['error']: - await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}}) - continue - + ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id) + if ask_user_error: + response_tool_calls = [ + tool_call + for tool_call in response_tool_calls + if tool_call.get('function', {}).get('name') != 'ask_user' + ] + elif ask_user_staged: if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'): await pause_for_tool_approval( metadata['chat_id'], @@ -5567,7 +5618,8 @@ async def streaming_chat_response_handler(response, ctx): tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full') if ( - tool_approval_mode == 'ask' + response_tool_calls + and tool_approval_mode == 'ask' and is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id') ): @@ -5789,17 +5841,6 @@ async def streaming_chat_response_handler(response, ctx): item['arguments'] = tc.get('function', {}).get('arguments', '{}') break - # Append a new empty message item for the next response - output.append( - { - 'type': 'message', - 'id': output_id('msg'), - 'status': 'in_progress', - 'role': 'assistant', - 'content': [{'type': 'output_text', 'text': ''}], - } - ) - # Emit citation sources to the frontend for display if citations_enabled: for source in tool_call_sources: @@ -5938,6 +5979,18 @@ async def streaming_chat_response_handler(response, ctx): } ) + if filter_functions: + new_form_data, _ = await process_filter_functions( + request=request, + filter_context=filter_context, + filter_functions=filter_functions, + filter_type='request', + form_data=new_form_data, + extra_params=extra_params, + ) + + new_form_data = normalize_messages_for_model(new_form_data) + res = await generate_chat_completion( request, new_form_data, @@ -6146,6 +6199,18 @@ async def streaming_chat_response_handler(response, ctx): ], } + if filter_functions: + new_form_data, _ = await process_filter_functions( + request=request, + filter_context=filter_context, + filter_functions=filter_functions, + filter_type='request', + form_data=new_form_data, + extra_params=extra_params, + ) + + new_form_data = normalize_messages_for_model(new_form_data) + res = await generate_chat_completion( request, new_form_data, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ea15adf75e..247ab72441 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -89,7 +89,7 @@ def get_allow_block_lists(filter_list): return allow_list, block_list -def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: +def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool: """ Checks if a string is allowed based on the provided filter list. :param string: The string or sequence of strings to check (e.g., domain or hostname). @@ -150,7 +150,7 @@ def _host_matches_pattern(host: str, pattern: str) -> bool: return host == pattern or host.endswith('.' + pattern) -def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: +def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool: """Allow/block a hostname (or list of hostnames / resolved IPs) against a WEB_FETCH_FILTER_LIST-style filter, matching on label boundaries. @@ -172,7 +172,7 @@ def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | Non return not is_host_blocked(hosts, filter_list) -def is_host_blocked(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: +def is_host_blocked(host: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool: """Whether a host or resolved address matches a block entry, ignoring any allow entries.""" _, block_list = get_allow_block_lists(filter_list) hosts = [host] if isinstance(host, str) else list(host or []) @@ -778,7 +778,7 @@ def openai_chat_chunk_message_template( model: str, content: str | None = None, reasoning_content: str | None = None, - tool_calls: list[dict | None] = None, + tool_calls: list[dict] | None = None, usage: dict | None = None, message_id: str | None = None, ) -> dict: @@ -809,7 +809,7 @@ def openai_chat_completion_message_template( model: str, message: str | None = None, reasoning_content: str | None = None, - tool_calls: list[dict | None] = None, + tool_calls: list[dict] | None = None, usage: dict | None = None, ) -> dict: template = openai_chat_message_template(model) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 1c648b7f38..4e1559200a 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -3,13 +3,12 @@ import copy import logging import sys -from aiocache import cached from fastapi import Request from open_webui.config import ( BYPASS_ADMIN_ACCESS_CONTROL, DEFAULT_ARENA_MODEL, ) -from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL +from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX from open_webui.functions import get_function_models from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config @@ -21,6 +20,7 @@ from open_webui.models.users import UserModel from open_webui.routers import ollama, openai from open_webui.socket.utils import RedisDict from open_webui.utils.access_control import has_access, has_base_model_access +from open_webui.utils.json_codec import JSONCodec from open_webui.utils.plugin import ( get_functions_cache, get_function_module_from_cache, @@ -29,6 +29,8 @@ from open_webui.utils.plugin import ( logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' + async def fetch_ollama_models(request: Request, user: UserModel = None): raw_ollama_models = await ollama.get_all_models(request, user=user) @@ -74,17 +76,32 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if refresh: await openai.get_all_models.cache.clear() await ollama.get_all_models.cache.clear() + redis = getattr(request.app.state, 'redis', None) + if redis is not None: + await redis.delete(BASE_MODELS_CACHE_KEY) + request.app.state.BASE_MODELS = [] - if ( - request.app.state.MODELS - and request.app.state.BASE_MODELS - and (config.get('models.base_models_cache') and not refresh) - ): + redis = getattr(request.app.state, 'redis', None) + use_cache = config.get('models.base_models_cache') and not refresh + base_models = None + + if use_cache and redis is not None: + cached_base_models = await redis.get(BASE_MODELS_CACHE_KEY) + if cached_base_models: + base_models = JSONCodec.loads(cached_base_models) + request.app.state.BASE_MODELS = base_models + else: + await openai.get_all_models.cache.clear() + await ollama.get_all_models.cache.clear() + elif use_cache and request.app.state.MODELS and request.app.state.BASE_MODELS: base_models = request.app.state.BASE_MODELS - else: + + if base_models is None: base_models = await get_all_base_models(request, user=user) if base_models: request.app.state.BASE_MODELS = base_models + if config.get('models.base_models_cache') and redis is not None: + await redis.set(BASE_MODELS_CACHE_KEY, JSONCodec.dumps(base_models)) else: base_models = request.app.state.BASE_MODELS @@ -374,6 +391,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids if filter_id in enabled_filter_ids ] + # Set order varies per process, and an unstable order defeats the RedisDict content signature. + filter_ids.sort() model['actions'] = [] for action_id in action_ids: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 4ab5c96504..0f18d2b753 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -148,15 +148,15 @@ async def build_tool_server_headers( cookies = {} if auth_type == 'bearer': - headers['Authorization'] = f'Bearer {connection.get("key", "")}' + headers.update(bearer_auth_header(connection.get('key', ''))) elif auth_type == 'session': cookies = request.cookies if hasattr(request, 'cookies') else {} - headers['Authorization'] = f'Bearer {request.state.token.credentials}' + headers.update(bearer_auth_header(request.state.token.credentials)) elif auth_type == 'system_oauth': cookies = request.cookies if hasattr(request, 'cookies') else {} oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: - headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' + headers.update(bearer_auth_header(oauth_token.get('access_token', ''))) elif auth_type in ('oauth_2.1', 'oauth_2.1_static'): try: splits = server_id.split(':') @@ -166,7 +166,7 @@ async def build_tool_server_headers( user.id, f'{connection_type}:{oauth_server_id}' ) if oauth_token: - headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' + headers.update(bearer_auth_header(oauth_token.get('access_token', ''))) except Exception as e: log.error(f'Error getting OAuth token: {e}') @@ -1338,7 +1338,13 @@ async def get_terminal_servers(request: Request): data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers') if data is not None: terminal_servers = JSONCodec.loads(data) - request.app.state.TERMINAL_SERVERS = terminal_servers + connections = await Config.get('terminal_server.connections', []) or [] + if terminal_servers or not any( + connection.get('url') and connection.get('enabled', True) for connection in connections + ): + request.app.state.TERMINAL_SERVERS = terminal_servers + else: + terminal_servers = None except Exception as e: log.error(f'Error fetching terminal_servers from Redis: {e}') @@ -1362,7 +1368,10 @@ async def get_terminal_tools( - Builds callables that route through the terminal proxy """ connections = await Config.get('terminal_server.connections', []) or [] - connection = next((c for c in connections if c.get('id') == terminal_id), None) + connection = next( + (terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id), + None, + ) if connection is None: raise RuntimeError(f"Terminal server '{terminal_id}' not found") if not connection.get('enabled', True): @@ -1374,7 +1383,7 @@ async def get_terminal_tools( # Find the cached spec data for this terminal terminal_servers = await get_terminal_servers(request) - server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None) + server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None) if server_data is None: raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable") diff --git a/package-lock.json b/package-lock.json index cd0cf349f4..3107b719b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 85b7735b46..f0fd6a4e65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.11.1", + "version": "0.11.2", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/app.css b/src/app.css index a5be2c2d78..df16e12121 100644 --- a/src/app.css +++ b/src/app.css @@ -108,23 +108,23 @@ textarea::-webkit-scrollbar-corner { } .input-prose { - @apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; + @apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-medium prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; } .input-prose-sm { - @apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; + @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; } .markdown-prose { - @apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0; + @apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0; } .markdown-prose-sm { - @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown-prose-xs { - @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown a { @@ -173,6 +173,7 @@ li p { } select { + color-scheme: light; background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E"); background-position: right 0rem center; background-repeat: no-repeat; @@ -186,6 +187,10 @@ select { -webkit-appearance: none; } +.dark select { + color-scheme: dark; +} + .dark select:not([class*='bg-transparent']) { @apply bg-gray-900 text-gray-300; } @@ -865,77 +870,134 @@ body { padding-bottom: 2rem; /* space for the bottom toolbar */ } -/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */ +/* Accessibility Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */ @layer utilities { html.high-contrast:not(.dark) :is(input, textarea)::placeholder { - color: var(--color-gray-600); + color: var(--color-gray-700); } html.high-contrast.dark :is(input, textarea)::placeholder { - color: var(--color-gray-500); + color: var(--color-gray-300); } } -/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark). +/* Accessibility Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark). :where() keeps these at low specificity so hover variants still win. */ @layer utilities { html:where(.high-contrast):not(:where(.dark)) .text-gray-400 { - color: var(--color-gray-600); + color: var(--color-gray-700); } - html:where(.high-contrast).dark .dark\:text-gray-600 { - color: var(--color-gray-400); + html:where(.high-contrast).dark + :is(.dark\:text-gray-400, .dark\:text-gray-500, .dark\:text-gray-600) { + color: var(--color-gray-300); + } + + html:where(.high-contrast).dark + :is( + .dark\:hover\:text-gray-100:hover, + .dark\:hover\:text-gray-200:hover, + .dark\:hover\:text-gray-300:hover, + .dark\:hover\:text-gray-400:hover + ) { + color: #fff; } /* Elements that hover to a lighter grey than the new resting colour. */ - html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover { - color: var(--color-gray-800); + html:where(.high-contrast):not(:where(.dark)) + :is( + .hover\:text-gray-500:hover, + .hover\:text-gray-600:hover, + .hover\:text-gray-700:hover, + .hover\:text-gray-800:hover + ) { + color: var(--color-gray-900); } } -/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1). +/* Accessibility Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1). The utility stays in the layer so hover variants still outrank it; the classes below are unlayered, so their override has to be too. */ @layer utilities { - html:where(.high-contrast):not(:where(.dark)) .text-gray-500 { - color: var(--color-gray-600); + html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-500, .text-gray-600) { + color: var(--color-gray-700); } } html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) { - color: var(--color-gray-600); + color: var(--color-gray-700); } -/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark. +html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) { + color: var(--color-gray-300); +} + +html.high-contrast:not(.dark) + :is( + #sidebar .hover\:bg-gray-100:hover, + #sidebar .group:hover .group-hover\:bg-gray-100, + .app-dropdown-menu :is(button, a):hover + ) { + background-color: var(--color-gray-200) !important; +} + +html.high-contrast.dark + :is( + #sidebar .dark\:hover\:bg-gray-900:hover, + #sidebar .group:hover .dark\:group-hover\:bg-gray-900, + .app-dropdown-menu :is(button, a):hover + ) { + background-color: var(--color-gray-800) !important; +} + +/* Accessibility Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark. text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */ @layer utilities { html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) { - color: var(--color-gray-600); + color: var(--color-gray-700); } - html:where(.high-contrast).dark .dark\:text-gray-700 { - color: var(--color-gray-400); + html:where(.high-contrast).dark :is(.dark\:text-gray-700, .dark\:text-gray-800) { + color: var(--color-gray-300); } /* Hover states that land lighter than the new resting colour. */ - html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover { - color: var(--color-gray-800); + html:where(.high-contrast):not(:where(.dark)) + :is( + .hover\:text-gray-500:hover, + .hover\:text-gray-600:hover, + .hover\:text-gray-700:hover, + .hover\:text-gray-800:hover + ) { + color: var(--color-gray-900); } - html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 { - color: var(--color-gray-800); + html:where(.high-contrast):not(:where(.dark)) + .group:hover + :is(.group-hover\:text-gray-500, .group-hover\:text-gray-600, .group-hover\:text-gray-700) { + color: var(--color-gray-900); + } + + html:where(.high-contrast).dark + .group:hover + :is( + .dark\:group-hover\:text-gray-200, + .dark\:group-hover\:text-gray-300, + .dark\:group-hover\:text-gray-400 + ) { + color: #fff; } } /* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */ html.high-contrast:not(.dark) .ai-autocompletion::after { - color: var(--color-gray-600); + color: var(--color-gray-700); } /* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no solid colour to raise; render it as flat text instead. */ html.high-contrast:not(.dark) .shimmer { background: none; - color: var(--color-gray-700); - -webkit-text-fill-color: var(--color-gray-700); + color: var(--color-gray-800); + -webkit-text-fill-color: var(--color-gray-800); } diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index fcd5b1708c..57f72fb27d 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -407,6 +407,36 @@ export const userSignOut = async () => { return res; }; +export const getLogoutRedirectUrl = (redirectUrl?: string | null) => { + const logoutUrl = new URL('/auth?state=logout', window.location.origin); + const postLogoutUrl = new URL('/auth', window.location.origin); + if (!redirectUrl) { + return logoutUrl.href; + } + + const url = new URL(redirectUrl, window.location.origin); + if (url.origin === window.location.origin && url.pathname === '/auth') { + url.searchParams.set('state', 'logout'); + return url.href; + } + + const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri'); + if (postLogoutRedirectUri) { + const configuredPostLogoutUrl = new URL(postLogoutRedirectUri, window.location.origin); + if ( + configuredPostLogoutUrl.origin === window.location.origin && + configuredPostLogoutUrl.pathname === '/auth' + ) { + url.searchParams.set('state', 'logout'); + } + return url.href; + } + + url.searchParams.set('post_logout_redirect_uri', postLogoutUrl.href); + url.searchParams.set('state', 'logout'); + return url.href; +}; + export const addUser = async ( token: string, name: string, diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index a8f310a962..ed7fe31561 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -292,6 +292,29 @@ export const downloadFileBlob = async ( return { blob, filename }; }; +export const downloadFilePreview = async ( + baseUrl: string, + apiKey: string, + path: string, + sessionId?: string +): Promise<{ blob: Blob; filename: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`; + const headers: Record = bearerHeaders(apiKey); + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch(() => null); + + if (!res) return null; + if (!res.ok) return null; + + const contentType = res.headers.get('content-type') ?? ''; + const filename = path.split('/').pop() ?? 'file'; + if (!contentType.includes('application/pdf')) return null; + + const blob = await res.blob().catch(() => null); + if (!blob) return null; + return { blob, filename }; +}; + export const archiveFromTerminal = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index ec5d04a862..bb897a0c98 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -100,6 +100,14 @@ class Filter: return body + def request(self, body: dict, __user__: Optional[dict] = None) -> dict: + # Modify the request body before each model/provider call. + print(f"request:{__name__}") + print(f"request:body:{body}") + print(f"request:user:{__user__}") + + return body + def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict: # Modify or analyze the response body after processing by the API. # This function is the post-processor for the API, which can be used to modify the response @@ -201,6 +209,14 @@ class Filter: return body + def request(self, body: dict, user: Optional[dict] = None) -> dict: + # Modify the request body before each model/provider call. + print(f"request:{__name__}") + print(f"request:body:{body}") + print(f"request:user:{user}") + + return body + def outlet(self, body: dict, user: Optional[dict] = None) -> dict: # Modify or analyze the response body after processing by the API. # This function is the post-processor for the API, which can be used to modify the response diff --git a/src/lib/components/admin/Settings/AdminSettingRow.svelte b/src/lib/components/admin/Settings/AdminSettingRow.svelte index 2dd338ae28..530a92bcf9 100644 --- a/src/lib/components/admin/Settings/AdminSettingRow.svelte +++ b/src/lib/components/admin/Settings/AdminSettingRow.svelte @@ -14,7 +14,7 @@ {label} -
+
diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 2099a79d1f..33fb351d9d 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -269,6 +269,14 @@ baseModels = await getBaseModels(localStorage.token, selectedTag); allModels = await getModels(localStorage.token); + + const providerModels = await getModels(localStorage.token, null, true); + const allModelIds = new Set(allModels.map((model: ModelListItem) => model.id)); + allModels = [ + ...allModels, + ...providerModels.filter((model: ModelListItem) => !allModelIds.has(model.id)) + ]; + const baseModelIds = new Set(baseModels.map((model: ModelListItem) => model.id)); models = allModels diff --git a/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte b/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte index 11043ed411..196fc212ab 100644 --- a/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte +++ b/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte @@ -168,7 +168,11 @@ {#if showCapabilities} -
+
{#if availableFeatures.length > 0} @@ -231,7 +235,12 @@ {#if showPromptSuggestions} -
+
{/if} diff --git a/src/lib/components/admin/Users.svelte b/src/lib/components/admin/Users.svelte index feebab8411..a695beb52f 100644 --- a/src/lib/components/admin/Users.svelte +++ b/src/lib/components/admin/Users.svelte @@ -25,6 +25,10 @@ scrollToTab(selectedTab); } + $: if (loaded && selectedTab) { + loadCounts(); + } + const scrollToTab = (tabId) => { const tabElement = document.getElementById(tabId); if (tabElement) { @@ -58,7 +62,6 @@ await goto('/'); } - await loadCounts(); loaded = true; const containerElement = document.getElementById('users-tabs-container'); diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index 66ea53fb1a..641925fb08 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -48,6 +48,10 @@ return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name); }); + $: if (loaded) { + adminGroupCount.set(filteredGroups.length); + } + /** @type {any} */ let defaultPermissions = {}; @@ -56,7 +60,6 @@ const setGroups = async () => { groups = await getGroups(localStorage.token); - adminGroupCount.set(groups.length); }; /** @param {any} updatedGroup */ diff --git a/src/lib/components/automations/AutomationEditor.svelte b/src/lib/components/automations/AutomationEditor.svelte index 4e42626e7b..1f086ac098 100644 --- a/src/lib/components/automations/AutomationEditor.svelte +++ b/src/lib/components/automations/AutomationEditor.svelte @@ -95,7 +95,7 @@ }; const formatSchedule = (rrule: string): string => { - if (rrule.includes('COUNT=1')) { + if (/COUNT=1(?!\d)/.test(rrule)) { const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/); if (match) { const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`); @@ -109,6 +109,9 @@ const parts: Record = {}; rrule + .split(/\s+/) + .filter((line) => !line.toUpperCase().startsWith('DTSTART')) + .join('') .replace('RRULE:', '') .split(';') .forEach((part) => { diff --git a/src/lib/components/automations/ScheduleDropdown.svelte b/src/lib/components/automations/ScheduleDropdown.svelte index 960ca64695..fba15c5220 100644 --- a/src/lib/components/automations/ScheduleDropdown.svelte +++ b/src/lib/components/automations/ScheduleDropdown.svelte @@ -106,7 +106,7 @@ export const parseRrule = (s: string) => { // Detect ONCE (COUNT=1 with DTSTART) - if (s.includes('COUNT=1')) { + if (/COUNT=1(?!\d)/.test(s)) { frequency = 'ONCE'; const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/); if (match) { @@ -116,7 +116,10 @@ return; } const parts: Record = {}; - s.replace('RRULE:', '') + s.split(/\s+/) + .filter((line) => !line.toUpperCase().startsWith('DTSTART')) + .join('') + .replace('RRULE:', '') .split(';') .forEach((p) => { const [k, v] = p.split('='); @@ -125,6 +128,7 @@ const freq = parts.FREQ || 'DAILY'; if (!['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) { frequency = 'CUSTOM'; + prevFrequency = 'CUSTOM'; customRrule = s; return; } diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index 8c0b724683..55df47a9ef 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -129,7 +129,12 @@ loading = true; try { const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime); - const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined; + let endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined; + if (endNs !== undefined && endNs < startNs) { + const duration = + event?.end_at && event.end_at > event.start_at ? event.end_at - event.start_at : 0; + endNs = startNs + duration; + } if (event && !event.meta?.automation_id) { const result = await updateCalendarEvent(localStorage.token, event.id, { diff --git a/src/lib/components/calendar/CalendarView.svelte b/src/lib/components/calendar/CalendarView.svelte index 51d4632f23..ec734956f3 100644 --- a/src/lib/components/calendar/CalendarView.svelte +++ b/src/lib/components/calendar/CalendarView.svelte @@ -31,7 +31,10 @@ const startDate = new Date(startMs); const endDate = new Date(endMs); const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); - const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime(); + const last = Math.max( + d.getTime(), + new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime() + ); while (d.getTime() <= last) { const key = d.getTime().toString(); (map[key] ??= []).push(e); @@ -90,7 +93,7 @@ const dayEndMs = dayStartMs + 86_400_000; return filteredEvents.filter((e) => { const startMs = e.start_at / NS; - const endMs = (e.end_at || e.start_at) / NS; + const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS); return startMs < dayEndMs && endMs >= dayStartMs; }); } diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 9aa7ad720b..875aee0e07 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1144,6 +1144,7 @@ const terminalEventHandler = (type: string, data: any) => { if (type === 'terminal:display_file') { if (!data?.path) return; + if ($settings?.terminalFileDisplay === 'inline') return; displayFileHandler(data.path, { showControls, showFileNavPath }, { page: data?.page }); } else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') { if (!data?.path) return; @@ -1219,7 +1220,11 @@ } else if (type === 'chat:active') { if (!data?.active) { taskIds = null; - if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) { + if ( + $chatId && + !$temporaryChatEnabled && + hasPendingAssistantLeaf(event?.message_id ?? null) + ) { await loadChat(); } if ($chatId && !$temporaryChatEnabled) { @@ -1499,9 +1504,9 @@ } catch {} }; - const hasPendingAssistantLeaf = () => - Object.values(history.messages).some( - (message) => + const hasPendingAssistantLeaf = (messageId: string | null = null) => + (messageId ? [history.messages[messageId]] : Object.values(history.messages)).some( + (message: any) => message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0 ); @@ -2113,8 +2118,9 @@ autoScroll = true; - await resetInput(); + // resetInput() must stay last: the selected model's defaults override the draft's selection. await restoreChatInput(sessionStorage.getItem('chat-input')); + await resetInput(); await chatId.set(''); await chatTitle.set(''); diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 4f64ef7ea1..2d7a3805d8 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -2,8 +2,10 @@ // Persists across mount/unmount cycles (module-level, not per-instance) let savedPath = '/'; let savedFileRoot = null; - const treeExpandedCache = new Map(); - const treeContentsCache = new Map(); + /** @type {Map} */ + const treeExpandedCache = new Map(); + /** @type {Map} */ + const treeContentsCache = new Map();
{:else if filePdfData !== null} - + {:else if fileSqliteData !== null} {:else if fileDocxData !== null} @@ -504,9 +515,10 @@
+