diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9a19bfa6b4..d50d38d848 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -88,9 +88,11 @@ This is to ensure large feature PRs are discussed with the community first, befo 🚨 DO NOT DELETE THE TEXT BELOW 🚨 Keep the "Contributor License Agreement" confirmation text intact. Deleting it will trigger the CLA-Bot to INVALIDATE your PR. + +Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA. --> -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. +- [ ] 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/build-release.yml b/.github/workflows/build-release.yml index 019fbb6bae..2ecb013e0b 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -27,28 +27,17 @@ jobs: echo "::set-output name=version::$VERSION" - name: Extract latest CHANGELOG entry - id: changelog run: | - CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md) - CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g') - echo "Extracted latest release notes from CHANGELOG.md:" - echo -e "$CHANGELOG_CONTENT" - echo "::set-output name=content::$CHANGELOG_ESCAPED" + VERSION="${{ steps.get_version.outputs.version }}" + awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md - name: Create GitHub release - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const changelog = `${{ steps.changelog.outputs.content }}`; - const release = await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: `v${{ steps.get_version.outputs.version }}`, - name: `v${{ steps.get_version.outputs.version }}`, - body: changelog, - }) - console.log(`Created release ${release.data.html_url}`) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.get_version.outputs.version }}" \ + --title "v${{ steps.get_version.outputs.version }}" \ + --notes-file /tmp/release-notes.md - name: Upload package to GitHub release uses: actions/upload-artifact@v4 diff --git a/.github/workflows/deploy-to-hf-spaces.yml b/.github/workflows/deploy-to-hf-spaces.yml deleted file mode 100644 index cda9f1eb4b..0000000000 --- a/.github/workflows/deploy-to-hf-spaces.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Deploy to HuggingFace Spaces - -on: - push: - branches: - - dev - - main - workflow_dispatch: - -jobs: - check-secret: - runs-on: ubuntu-latest - outputs: - token-set: ${{ steps.check-key.outputs.defined }} - steps: - - id: check-key - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - if: "${{ env.HF_TOKEN != '' }}" - run: echo "defined=true" >> $GITHUB_OUTPUT - - deploy: - runs-on: ubuntu-latest - needs: [check-secret] - if: needs.check-secret.outputs.token-set == 'true' - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - lfs: true - - - name: Remove git history - run: rm -rf .git - - - name: Prepend YAML front matter to README.md - run: | - echo "---" > temp_readme.md - echo "title: Open WebUI" >> temp_readme.md - echo "emoji: 🐳" >> temp_readme.md - echo "colorFrom: purple" >> temp_readme.md - echo "colorTo: gray" >> temp_readme.md - echo "sdk: docker" >> temp_readme.md - echo "app_port: 8080" >> temp_readme.md - echo "---" >> temp_readme.md - cat README.md >> temp_readme.md - mv temp_readme.md README.md - - - name: Configure git - run: | - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - name: Set up Git and push to Space - run: | - git init --initial-branch=main - git lfs install - git lfs track "*.ttf" - git lfs track "*.jpg" - rm demo.png - rm banner.png - git add . - git commit -m "GitHub deploy: ${{ github.sha }}" - git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main diff --git a/CHANGELOG.md b/CHANGELOG.md index e5579c2832..250d70cc06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,149 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.10] - 2026-03-08 + +### Added + +- 🔐 **Custom OIDC logout endpoint.** Administrators can now configure a custom OpenID Connect logout URL via OPENID_END_SESSION_ENDPOINT, enabling logout functionality for OIDC providers that require custom endpoints like AWS Cognito. [Commit](https://github.com/open-webui/open-webui/commit/3f350f865920daf2844769a758b2d2e6a7ee3efa) +- 🗄️ **MariaDB Vector community support.** Added MariaDB Vector as a new vector database backend, enabling deployments with VECTOR_DB=mariadb-vector; supports cosine and euclidean distance strategies with configurable HNSW indexing. [#21931](https://github.com/open-webui/open-webui/pull/21931) +- 📝 **Task message truncation.** Chat messages sent to task models for title and tag generation can now be truncated using a filter in the prompt template, reducing token usage and processing time for long conversations. [#21499](https://github.com/open-webui/open-webui/issues/21499) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for Portuguese (Brazil), Spanish, and Malay were enhanced and expanded. + +### Fixed + +- 🔗 **Pipeline filter HTTP errors.** Fixed a bug where HTTP errors in pipeline inlet/outlet filters would silently corrupt the user's chat payload; errors are now properly raised before parsing the response. [#22445](https://github.com/open-webui/open-webui/pull/22445) +- 📚 **Knowledge file embedding updates.** Fixed a bug where updating knowledge files left old embeddings in the database, causing search results to include duplicate and stale data. [#20558](https://github.com/open-webui/open-webui/issues/20558) +- 📁 **Files list stability.** Fixed the files list ordering to use created_at with id as secondary sort, ensuring consistent ordering and preventing page crashes when managing many files. [#21879](https://github.com/open-webui/open-webui/issues/21879) +- 📨 **Teams webhook crash.** Fixed a TypeError crash in the Teams webhook handler when user data is missing from the event payload. [#22444](https://github.com/open-webui/open-webui/pull/22444) +- 🛠️ **Process shutdown handling.** Fixed bare except clauses in the main process that prevented clean shutdown; replaced with proper exception handling. [#22423](https://github.com/open-webui/open-webui/pull/22423) +- 🐳 **Docker deployment startup.** Docker deployments now start correctly; the missing OpenTelemetry system metrics dependency was added. [#22447](https://github.com/open-webui/open-webui/pull/22447), [#22401](https://github.com/open-webui/open-webui/issues/22401) +- 🛠️ **Tool access for non-admin users.** Fixed a NameError that prevented non-admin users from viewing tools; the missing has_access function is now properly imported. [#22393](https://github.com/open-webui/open-webui/issues/22393) +- 🔐 **OAuth error handling.** Fixed a bug where bare except clauses silently caught SystemExit and KeyboardInterrupt, preventing clean process shutdown during OAuth authentication. [#22420](https://github.com/open-webui/open-webui/pull/22420) +- 🛠️ **Exception error messages.** Fixed three locations where incorrect exception raising caused confusing TypeError messages instead of proper error descriptions, making debugging much easier. [#22446](https://github.com/open-webui/open-webui/pull/22446) +- 📄 **YAML file processing.** Fixed an error when uploading YAML files with Docling enabled; YAML and YML files are now properly recognized as text files and processed correctly. [#22399](https://github.com/open-webui/open-webui/pull/22399), [#22263](https://github.com/open-webui/open-webui/issues/22263) +- 📅 **Time range month names.** Fixed month names in time range labels appearing in the wrong language when OS regional settings differ from browser language; month names now consistently display in English. [#22454](https://github.com/open-webui/open-webui/pull/22454) +- 🔐 **OAuth error URL encoding.** Fixed OAuth error messages with special characters causing malformed redirect URLs; error messages are now properly URL-encoded. [#22415](https://github.com/open-webui/open-webui/pull/22415) +- 🛠️ **Internal tool method filtering.** Tools no longer expose internal methods starting with underscore to the LLM, reducing clutter and improving accuracy. [#22408](https://github.com/open-webui/open-webui/pull/22408) +- 🔊 **Azure TTS locale extraction.** Fixed Azure text-to-speech using incomplete locale codes in SSML; now correctly uses full locale like "en-US" instead of just "en". [#22443](https://github.com/open-webui/open-webui/pull/22443) +- 🎤 **Azure speech transcription errors.** Improved Azure AI Speech error handling to display user-friendly messages instead of generic connection errors; empty transcripts, no language identified, and other Azure-specific errors now show clear descriptions. [#20485](https://github.com/open-webui/open-webui/issues/20485) +- 📊 **Analytics group filtering.** Fixed token usage analytics not being filtered by user group; the query now properly respects group filters like other analytics metrics. [#22167](https://github.com/open-webui/open-webui/pull/22167) +- 🔍 **Web search favicon fallback.** Fixed web search sources showing broken image icons when favicons couldn't be loaded from external sources; now falls back to the default Open WebUI favicon. [#21897](https://github.com/open-webui/open-webui/pull/21897) +- 🔄 **Custom model fallback.** Fixed custom model fallback not working when the base model is unavailable; the base model ID is now correctly retrieved from model info instead of empty params. [#22456](https://github.com/open-webui/open-webui/issues/22456) +- 🖼️ **Pending message image display.** Fixed images in queued messages appearing blank; image thumbnails are now properly displayed in the pending message queue. [#22256](https://github.com/open-webui/open-webui/issues/22256) +- 🛠️ **File metadata sanitization.** Fixed file uploads failing with JSON serialization errors when metadata contained non-serializable objects like callable functions; metadata is now sanitized before database insertion. [#20561](https://github.com/open-webui/open-webui/issues/20561) + +## [0.8.9] - 2026-03-07 + +### Added + +- ▶️ **Open Terminal notebook cell execution.** Users can now run Jupyter Notebook code cells directly in the Open Terminal file navigator, execute entire notebooks with a single click, edit and modify cells before running, and control the kernel - bringing full interactive notebook execution to the browser. [Commit](https://github.com/open-webui/open-webui/commit/4b3ed3e802d6f2ec8ee7caf358af810b7d09f789) +- 🗃️ **Open Terminal SQLite browser.** Users can now browse SQLite database files directly in the Open Terminal file navigator, viewing tables and running queries without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/a181b4a731a9ec7856be08d0b045a454d1341cf4) +- 📉 **Open Terminal Mermaid diagram rendering.** Markdown files with Mermaid code blocks are now rendered as diagrams directly in the Open Terminal file navigator, making it easier to visualize flowcharts and other diagrams. [Commit](https://github.com/open-webui/open-webui/commit/aaa49bdd6d6e5c10e8be554039d3cac673008fc2) +- 📓 **Open Terminal Jupyter Notebook previews.** Users can now preview Jupyter Notebook files directly in the Open Terminal file navigator, making it easier to view notebook content without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/b081e33c0a37585a1ee60b6e0e1ea03457f1e5f4) +- 🔃 **Open Terminal auto-refresh.** The Open Terminal file navigator now automatically refreshes when the model writes or modifies files, keeping the view in sync without manual refresh. [Commit](https://github.com/open-webui/open-webui/commit/828656b35f04bf486609183799cf8aa2e9850a76) +- 📎 **Open Terminal file copy button.** Users can now copy file contents directly to clipboard in the Open Terminal file navigator with a single click, making it easier to quickly grab file content without downloading. [Commit](https://github.com/open-webui/open-webui/commit/f5ea1ce250cb02fbc583c6cb3f52a923912d0178) +- 💻 **Code syntax highlighting and XLSX improvements in Open Terminal.** Code files now display with syntax highlighting in the Open Terminal file navigator, and XLSX spreadsheets now show column headers and row numbers for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/f962bae98306ea9264967b78b803397f4821f9b0) +- 🌳 **Open Terminal JSON tree view.** JSON, JSONC, JSONL, and JSON5 files now display as interactive collapsible tree views in the Open Terminal file navigator, and SVG files render as preview images with syntax highlighting support. [Commit](https://github.com/open-webui/open-webui/commit/f4c38e6001dd9d4853ed923e0bc5e790c4fd9941) +- 🛜 **Open Terminal port viewing.** Users can now view listening ports in the Open Terminal file navigator and open proxy connections to them directly from the UI. [Commit](https://github.com/open-webui/open-webui/commit/e08341dab3bb10e26a64eb44cbebd2d507087b03) +- 🎬 **Open Terminal video previews.** Users can now preview video and audio files directly in the Open Terminal file navigator, making it easier to view media without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/c40f26946f2eaeb1587a1f8b0c643b4a5121fc06) +- ✏️ **Open Terminal HTML editing.** Users can now edit HTML source files in Open Terminal with CodeMirror editor, and the save button is properly hidden in preview mode. [Commit](https://github.com/open-webui/open-webui/commit/7806cd5aef9fb0505b2c642ef70599a403cf14ba) +- 📄 **Open Terminal DOCX preview.** Word documents generated or modified by the AI can now be viewed directly in the file navigator with formatted text, tables, and images rendered inline — no need to download and open in a separate application. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📊 **Open Terminal XLSX preview.** Excel spreadsheets in the file navigator now render as interactive tables with column headers and row numbers, making it easy to verify data the AI has generated or processed. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📽️ **Open Terminal PPTX preview.** PowerPoint presentations created by the AI can now be viewed slide-by-slide directly in the file navigator, enabling quick review and iteration without leaving the browser. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📁 **Pyodide file system support.** Users can now upload files for Python code execution in the code interpreter. Uploaded files are available in the `/mnt/uploads/` directory, and code can write output files there for download. The file system persists across code executions within the same session. The code interpreter now also informs models that pip install is not available in the Pyodide environment, guiding them to use alternative approaches with available modules. [#3583](https://github.com/open-webui/open-webui/issues/3583), [Commit](https://github.com/open-webui/open-webui/commit/ce0ca894fea8a2904bc6f832ff186d5fe53dd0b9), [Commit](https://github.com/open-webui/open-webui/commit/989938856fdb4b4afa584ae2d18c88d5be614ae2) +- 🧰 **Tool files access.** Tools can now access the files from the current chat context via the files property in their metadata, enabling more powerful tool integrations. [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0) +- ⚡ **Chat performance.** Chat messages now load and display significantly faster thanks to optimized markdown rendering, eliminating delays when viewing messages with mathematical expressions. [#22196](https://github.com/open-webui/open-webui/pull/22196), [#20878](https://github.com/open-webui/open-webui/discussions/20878) +- 📜 **Message list performance.** Improved message list rendering performance by optimizing array operations, reducing complexity from O(n²) to O(n). [#22280](https://github.com/open-webui/open-webui/pull/22280) +- 🧵 **Streaming markdown performance.** Improved chat responsiveness during streaming by skipping unnecessary markdown re-parsing when the content hasn't changed, eliminating wasted processing during model pauses. [#22183](https://github.com/open-webui/open-webui/pull/22183) +- 🏃 **Chat streaming performance.** Chat streaming is now faster for users not using the voice call feature by skipping unnecessary text parsing that was running on every token. [#22195](https://github.com/open-webui/open-webui/pull/22195) +- 🔖 **Source list performance.** Source lists in chat now render faster thanks to optimized computation that avoids unnecessary recalculations, including moving sourceIds computation to a reactive variable. [#22279](https://github.com/open-webui/open-webui/pull/22279), [Commit](https://github.com/open-webui/open-webui/commit/88af78c), [Commit](https://github.com/open-webui/open-webui/commit/339ed1d72e100c89d8eb26de761dfefe842ef90c) +- 💨 **Chat message tree operations.** Chat message tree operations are now significantly faster, improving overall chat responsiveness. [#22194](https://github.com/open-webui/open-webui/pull/22194) +- 🚀 **Initial page load speed.** Page load is now significantly faster thanks to deferred loading of the syntax highlighting library, reducing the initial JavaScript bundle by several megabytes. [#22304](https://github.com/open-webui/open-webui/pull/22304) +- 🗓️ **Action priority query optimization.** Improved performance of action priority resolution by fixing an N+1 query pattern, reducing database round-trips when loading model actions. [#22301](https://github.com/open-webui/open-webui/pull/22301) +- 🔑 **API key middleware optimization.** The API key restriction middleware was converted to a pure ASGI middleware for improved streaming performance, removing per-chunk call overhead. [#22188](https://github.com/open-webui/open-webui/pull/22188) +- 🏎️ **Model list loading performance.** Model lists now load significantly faster thanks to optimized custom model matching that uses dictionary lookups instead of nested loops. [#22299](https://github.com/open-webui/open-webui/pull/22299), [Commit](https://github.com/open-webui/open-webui/commit/29160741a3defa8768a43100cb6e63c56400279c), [Commit](https://github.com/open-webui/open-webui/commit/03c6caac1fc8625f85cf1164f5a977be8005c1bc) +- ⏱️ **Event call timeout configuration.** Administrators can now configure the WebSocket event call timeout via the WEBSOCKET_EVENT_CALLER_TIMEOUT environment variable, giving users more time to respond to event_call forms instead of timing out after 60 seconds. [#22222](https://github.com/open-webui/open-webui/pull/22222), [#22220](https://github.com/open-webui/open-webui/issues/22220) +- 🔁 **File refresh button visibility.** The refresh button in the chat file navigator now appears when viewing files as well as directories, allowing users to refresh the file view at any time. [Commit](https://github.com/open-webui/open-webui/commit/49a2e5bf573415dae6d4c7e5bd635e499c8de77a) +- 📂 **Nested folders support.** Users can now create subfolders within parent folders, improving organization of chats. A new "Create Subfolder" option is available in the folder context menu. [#22073](https://github.com/open-webui/open-webui/pull/22073), [Commit](https://github.com/open-webui/open-webui/commit/8913f37c3d8fde7dea6d54a550357f1d495b3941) +- 🔔 **Banner loading on navigation.** Admin-configured banners now load when navigating to the homepage, not just on page refresh, ensuring users see new banners immediately. [#22340](https://github.com/open-webui/open-webui/pull/22340), [#22180](https://github.com/open-webui/open-webui/issues/22180) +- 📡 **System metrics via OpenTelemetry.** Administrators can now monitor Python runtime and system metrics including CPU, memory, garbage collection, and thread counts through the existing OpenTelemetry pipeline. [#22265](https://github.com/open-webui/open-webui/pull/22265) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for French, Finnish, Turkish, German, Simplified Chinese, and Traditional Chinese were enhanced and expanded. +- 🔍 **Web search tool guidance.** The web search tool description was updated to encourage direct usage without first checking knowledge bases, making it clearer for users who want to search the web immediately. [#22264](https://github.com/open-webui/open-webui/pull/22264) + +### Fixed + +- 🗄️ **Migration memory usage.** Database migration on large deployments now processes messages in batches instead of loading everything into memory, preventing out-of-memory errors during upgrades. [#21542](https://github.com/open-webui/open-webui/pull/21542), [#21539](https://github.com/open-webui/open-webui/discussions/21539) +- 🔒 **SQLCipher connection stability.** Fixed a crash that occurred when using database encryption with SQLCipher by changing the default connection pool behavior, ensuring stable operation during multi-threaded operations like user signup. [#22273](https://github.com/open-webui/open-webui/pull/22273), [#22258](https://github.com/open-webui/open-webui/issues/22258) +- 🛑 **Stop sequence error.** Fixed a bug where setting stop sequences on a model caused the chat to fail with a split error, preventing any responses from being returned. The fix handles both string and array formats for stop tokens. [#22251](https://github.com/open-webui/open-webui/issues/22251), [Commit](https://github.com/open-webui/open-webui/commit/c7d1d1e390a79c6c86d4bfe439fd7de6f5fb060f) +- 🔐 **Microsoft OAuth refresh token fix.** Fixed a bug where Microsoft OAuth refresh token requests failed with error AADSTS90009 by adding support for the required scope parameter. Users can now stay logged in reliably with Microsoft OAuth. [#22359](https://github.com/open-webui/open-webui/pull/22359) +- 🛠️ **Parameterless tool calls.** Fixed parameterless tool calls failing during streaming by correcting the default arguments initialization, eliminating unnecessary model retries. [#22189](https://github.com/open-webui/open-webui/pull/22189) +- 🔧 **Tool call streaming fixes.** Fixed two bugs where streaming tool calls failed silently for models like GPT-5: function names were incorrectly duplicated when sent in multiple delta chunks, and arguments containing multiple JSON objects were not properly split. Tools now execute correctly instead of failing without explanation. [#22177](https://github.com/open-webui/open-webui/issues/22177), [Commit](https://github.com/open-webui/open-webui/commit/d7efdcce2b1cdbe1637a469294bf9d52dbacab53), [Commit](https://github.com/open-webui/open-webui/commit/459a60a24240eab33441ed50f4f68cc27e65a037) +- 🔗 **Tool server URL trailing slash.** Fixed tool server connection failures when URLs have trailing slashes by stripping them before path concatenation. Previously, URLs like "http://host:8080/v1/" + "/openapi.json" produced double-slash URLs that some servers rejected. [#22116](https://github.com/open-webui/open-webui/pull/22116), [#21917](https://github.com/open-webui/open-webui/issues/21917) +- 🛡️ **Citation parser error handling.** Fixed crashes when tools return error strings instead of expected data structures by adding type guards to the citation parser. The system now returns an empty source list instead of crashing with AttributeError. [#22118](https://github.com/open-webui/open-webui/pull/22118) +- 🧠 **Artifacts memory leak.** Fixed a memory leak where Svelte store subscriptions in the Artifacts component were not properly cleaned up when the component unmounted, causing memory to accumulate over time. [#22303](https://github.com/open-webui/open-webui/pull/22303) +- ♾️ **Artifacts reactive loop fix.** Fixed an infinite reactive loop in chat when artifacts are present by moving the animation frame logic outside the reactive block, preventing continuous re-rendering and CPU usage. [#22238](https://github.com/open-webui/open-webui/pull/22238), [Commit](https://github.com/open-webui/open-webui/commit/626fcff417afba642f4f71e0498267a21435c524) +- 🔀 **Artifact navigation.** Artifact navigation via arrow buttons now works correctly; the selected artifact is no longer reset when content updates. [#22239](https://github.com/open-webui/open-webui/pull/22239) +- 🧩 **Artifact thinking block fix.** Fixed a bug where HTML preview rendered code blocks inside thinking blocks for certain models like Mistral and Z.ai, causing stray code with ">" symbols to appear before the actual artifact. The fix strips thinking blocks before extracting code for artifact rendering. [#22267](https://github.com/open-webui/open-webui/issues/22267), [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0) +- 💬 **Floating Quick Actions availability.** Fixed an issue where the "Ask" and "Explain" Floating Quick Actions were missing when selecting text in chats that used a model that is no longer available. [#22149](https://github.com/open-webui/open-webui/pull/22149), [#22139](https://github.com/open-webui/open-webui/issues/22139) +- 💡 **Follow-up suggestions.** Fixed follow-up suggestions not appearing by correcting contradictory format instructions in the prompt template, ensuring the LLM returns the correct JSON object format. [#22212](https://github.com/open-webui/open-webui/pull/22212) +- 🔊 **TTS thinking content.** Fixed TTS playback reading think tags instead of skipping them by handling edge cases where code blocks inside thinking content prevented proper tag removal. [#22237](https://github.com/open-webui/open-webui/pull/22237), [#22197](https://github.com/open-webui/open-webui/issues/22197) +- 🎨 **Button spinner alignment.** Button spinners across multiple modals now align correctly and stay on the same line as the button text, fixing layout issues when loading states are displayed. [#22227](https://github.com/open-webui/open-webui/pull/22227) +- 📶 **Terminal keepalive.** Terminal connections now stay active without being closed by idle timeouts from proxies or load balancers, and spurious disconnection messages no longer appear. [Commit](https://github.com/open-webui/open-webui/commit/ca2aaf0321c219d041e92e2c0c842a4e424732ef) +- 📥 **Chat archive handler.** The archive button in the chat navbar now actually archives the chat and refreshes the chat list, instead of doing nothing. [#22229](https://github.com/open-webui/open-webui/pull/22229) +- 🐍 **BeautifulSoup4 dependency.** Added the missing BeautifulSoup4 package to backend requirements, fixing failures when using features that depend on HTML parsing. [#22231](https://github.com/open-webui/open-webui/pull/22231) +- 👥 **Group users default sort.** Group members in the admin panel now sort by last active time by default instead of creation date, making it easier to find active users. [#22211](https://github.com/open-webui/open-webui/pull/22211) +- 🔓 **Tool access permissions.** Users can now change tool and skill access permissions from private to public without errors. [#22325](https://github.com/open-webui/open-webui/pull/22325), [#22324](https://github.com/open-webui/open-webui/issues/22324) +- 🖥️ **Open Terminal permission fix.** Open Terminal is now visible without requiring "Allow Speech to Text" permission, fixing an issue where users without microphone access couldn't access the terminal feature. [#22374](https://github.com/open-webui/open-webui/issues/22374), [Commit](https://github.com/open-webui/open-webui/commit/70a31a9a57bdd0690ac270f31ebd1b46e8fdfa98) +- 📌 **Stale pinned models cleanup.** Pinned models that are deleted or hidden are now automatically unpinned, keeping your pinned models list up to date. [Commit](https://github.com/open-webui/open-webui/commit/af4500e5040c8343d339cd88dd1d2fb6138c7a72) +- 📏 **OpenTelemetry metric descriptions.** Fixed conflicting metric instrument descriptions that caused warnings in the OpenTelemetry collector, resulting in cleaner telemetry logs for administrators. [#22293](https://github.com/open-webui/open-webui/pull/22293) +- 🔢 **Non-streaming token tracking.** Token usage from non-streaming chat responses is now correctly saved to the database, fixing missing token counts in the Admin Panel analytics. Previously, non-streaming responses saved NULL usage data, causing messages to be excluded from token aggregation queries. [#22166](https://github.com/open-webui/open-webui/pull/22166) +- ⌨️ **Inline code typing.** Fixed a bug where typing inline code with backticks incorrectly deleted the character immediately before the opening backtick, so text formatted as inline code now correctly produces the full word instead of missing the last character. [#20417](https://github.com/open-webui/open-webui/issues/20417), [Commit](https://github.com/open-webui/open-webui/commit/e303c3da3b174da9e92a79b174f85ba574ca06ef) +- 📝 **Variable input newlines.** Fixed a bug where variables containing newlines were not displayed correctly in chat messages, and input values from Windows systems are now properly normalized to use standard line endings. [#21447](https://github.com/open-webui/open-webui/issues/21447), [Commit](https://github.com/open-webui/open-webui/commit/7b2f597b30c77ef300d1966e1c6a3edfdb0c465d) +- 📷 **Android photo capture.** Fixed an issue where the first photo taken in chat appeared completely black on some Android devices by using an alternative canvas export method. [#22317](https://github.com/open-webui/open-webui/pull/22317) +- 🪟 **Open Terminal Windows path fix.** Fixed a bug where navigating back to parent directories on Windows added an incorrect leading slash, causing directory loads to fail. Paths are now properly normalized for Windows drive letters. [#22352](https://github.com/open-webui/open-webui/issues/22352), [Commit](https://github.com/open-webui/open-webui/commit/044fd1bd15cae06a5c56a321ca79d8362942f66a) +- 🖼️ **Chat overview profile image sizing.** Fixed a bug where profile images in the chat overview could shrink incorrectly in tight spaces. The images now maintain their proper size with the flex-shrink-0 property. [#22261](https://github.com/open-webui/open-webui/pull/22261) +- 📨 **Queued messages display.** Fixed an issue where queued messages could be cut off or hidden. The queued messages area now scrolls properly when content exceeds the visible area, showing up to 25% of the viewport height. [#22176](https://github.com/open-webui/open-webui/pull/22176) +- 🖌️ **Image generation in temporary chats.** Generated images now display correctly in temporary chat mode when using builtin image generation tools. Previously, images were not shown because the code was overwriting the image list with a null database response. [#22330](https://github.com/open-webui/open-webui/pull/22330), [#22309](https://github.com/open-webui/open-webui/issues/22309) +- 🤖 **Ollama model unload fix.** Fixed a bug where unloading a model from Ollama via the Open WebUI proxy failed with a "Field required" error for the prompt field. The proxy now correctly allows omitting the prompt when using keep_alive: 0 to unload models. [#22260](https://github.com/open-webui/open-webui/issues/22260), [Commit](https://github.com/open-webui/open-webui/commit/95b65ff751f91131b633cb128ff2decdd87c4a85) +- 🏷️ **Banner type dropdown fix.** Fixed a bug where selecting a banner type required two clicks to register, as the first selection was being swallowed due to DOM structure changes. The dropdown now works correctly on the first click. [#22378](https://github.com/open-webui/open-webui/pull/22378) +- 📈 **Analytics URL encoding fix.** Fixed a bug where the Analytics page failed to load data for models with slashes in their ID, such as "anthropic/claude-opus-4.6". The frontend now properly URL-encodes forward slashes, allowing model analytics to load correctly. [#22380](https://github.com/open-webui/open-webui/issues/22380), [#22382](https://github.com/open-webui/open-webui/pull/22382) +- 📋 **Analytics chat list duplicate fix.** Fixed a bug where the Analytics page chat list threw an "each_key_duplicate" Svelte error when chat IDs were duplicated during pagination. The fix adds deterministic ordering to prevent duplicate entries. [#22383](https://github.com/open-webui/open-webui/pull/22383) +- 📂 **Folder knowledge base native tool call fix.** Fixed a bug where folders with attached knowledge bases were querying the knowledge base twice when using native tool call mode. The fix now correctly separates knowledge files from regular attachments, letting the builtin query_knowledge_files tool handle knowledge searches instead of duplicating RAG queries. [#22236](https://github.com/open-webui/open-webui/issues/22236), [Commit](https://github.com/open-webui/open-webui/commit/967b1137dcb7a52615f17d086ee89095bb9b60f3), [Commit](https://github.com/open-webui/open-webui/commit/80b5896b70d07ea868e2010b187430d43c9808f0) + +## [0.8.8] - 2026-03-02 + +### Added + +- 📁 **Open Terminal file moving.** Users can now move files and folders between directories in the Open Terminal file browser by dragging and dropping them. [Commit](https://github.com/open-webui/open-webui/commit/0c42cd2c012f9f49816adac897e2b46573b3cb6c), [Commit](https://github.com/open-webui/open-webui/commit/72951324dfeef64e09f4776898d675bc1c44f040), [Commit](https://github.com/open-webui/open-webui/commit/395098c6f1b7499d37ad55145a5931431d3e72e9), [Commit](https://github.com/open-webui/open-webui/commit/11487d66fc1a2dfafbdaa2b7ef939a86caaf3872) +- 📄 **Open Terminal HTML file preview.** Users can now preview HTML files directly in the Open Terminal file browser, with a rendered iframe view and source toggle, enabling iterative AI editing of HTML files. [Commit](https://github.com/open-webui/open-webui/commit/3909b62ffcf49839fa57346ed8487ae759811503), [Commit](https://github.com/open-webui/open-webui/commit/933a3bbbd3f4fc3eeb0ec52c7965e9ac1c4cea39) +- 🌐 **Open Terminal WebSocket proxy.** Added a new WebSocket proxy endpoint for interactive terminal sessions, enabling real-time bidirectional terminal communication with the terminal server. [Commit](https://github.com/open-webui/open-webui/commit/4f6cb771f1afded09aad6199cdb244dd8a6c77a6) +- ⚙️ **Open Terminal feature toggle.** Administrators can now enable or disable the Interactive Terminal feature for Open Terminal via configuration on the terminal server, controlling access to terminal routes. [Commit](https://github.com/open-webui/open-webui/commit/b5c3395f79bcc7ff5bc1d82bb86a60583bb3b5bd) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for Simplified Chinese, Traditional Chinese, Irish, and Catalan were enhanced and expanded. + +### Fixed + +- 🔧 **Middleware variable shadowing.** Fixed a variable shadowing issue in the middleware that could cause incorrect tool output processing during chat. [#22145](https://github.com/open-webui/open-webui/pull/22145) +- ⚡ **ChatControls reactivity fix.** Fixed a Svelte reactivity issue where the active tab state in the ChatControls panel was not properly saved when switching between chats. [#22127](https://github.com/open-webui/open-webui/pull/22127) +- 🔧 **ChatControls TypeScript fix.** Fixed a TypeScript syntax error in ChatControls.svelte where the module script block was missing lang="ts", causing esbuild to fail during vite dev. [#22131](https://github.com/open-webui/open-webui/pull/22131) +- 🔌 **Open Terminal tools for direct connections.** Fixed an issue where Open Terminal tools were not available to the model when the terminal was configured via direct connection settings, ensuring users can now interact with terminal files and operations through the AI. [#22137](https://github.com/open-webui/open-webui/issues/22137) +- 📜 **Chat history pagination.** Fixed an issue where older messages in long chats were not loaded when scrolling to the top. [Commit](https://github.com/open-webui/open-webui/commit/d7147d6cddfd314f0f1be77b15cec406a609ef36), [Commit](https://github.com/open-webui/open-webui/commit/c701ebe07bd152eecb42b0bf6de26071358a5c76) +- 🔧 **Terminal tool null parameter handling.** Fixed a bug where null parameters in terminal tool calls were sent as the string "None" instead of being omitted, causing 422 validation errors from the open-terminal server. [#22124](https://github.com/open-webui/open-webui/issues/22124), [#22144](https://github.com/open-webui/open-webui/pull/22144) + +### Changed + +## [0.8.7] - 2026-03-01 + +### Fixed + +- 🔒 **Connection access control privacy.** Tool server and terminal connections without explicit access grants are now private (admin-only) by default, fixing a bug where connections configured with no access grants were visible to all users instead of being restricted. [Commit](https://github.com/open-webui/open-webui/commit/2751a0f0b) +- 🧠 **ChatControls memory leak.** The ChatControls panel no longer leaks event listeners, ResizeObserver instances, and media query handlers when navigating between chats, fixing memory accumulation that could degrade performance during extended use. [#22112](https://github.com/open-webui/open-webui/pull/22112) +- 💾 **Temporary chat params preservation.** Model parameters are now correctly saved when creating a temporary chat, ensuring custom settings like temperature and top_p persist across the session. [Commit](https://github.com/open-webui/open-webui/commit/fe837d80e) +- ⚡ **Faster artifact content updates.** Artifact content extraction during streaming is now debounced via requestAnimationFrame, reducing redundant DOM reads and improving CPU efficiency when tokens arrive faster than the browser can paint. [Commit](https://github.com/open-webui/open-webui/commit/6863ca482) + ## [0.8.6] - 2026-03-01 ### Added diff --git a/Dockerfile b/Dockerfile index 1c0ae0b3c8..2a7e9bd20c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -127,6 +127,7 @@ RUN chown -R $UID:$GID /app $HOME RUN apt-get update && \ apt-get install -y --no-install-recommends \ git build-essential pandoc gcc netcat-openbsd curl jq \ + libmariadb-dev \ python3-dev \ ffmpeg libsm6 libxext6 zstd \ && rm -rf /var/lib/apt/lists/* diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 76ad9c092b..8a30c0c1f2 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -339,6 +339,12 @@ ENABLE_OAUTH_SIGNUP = PersistentConfig( os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true", ) +OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = PersistentConfig( + "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", + "oauth.refresh_token_include_scope", + os.environ.get("OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", "False").lower() == "true", +) + OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig( "OAUTH_MERGE_ACCOUNTS_BY_EMAIL", @@ -463,6 +469,12 @@ OPENID_PROVIDER_URL = PersistentConfig( os.environ.get("OPENID_PROVIDER_URL", ""), ) +OPENID_END_SESSION_ENDPOINT = PersistentConfig( + "OPENID_END_SESSION_ENDPOINT", + "oauth.oidc.end_session_endpoint", + os.environ.get("OPENID_END_SESSION_ENDPOINT", ""), +) + OPENID_REDIRECT_URI = PersistentConfig( "OPENID_REDIRECT_URI", "oauth.oidc.redirect_uri", @@ -838,13 +850,18 @@ def load_oauth_providers(): if FEISHU_CLIENT_ID.value: configured_providers.append("Feishu") - if configured_providers and not OPENID_PROVIDER_URL.value: + if ( + configured_providers + and not OPENID_PROVIDER_URL.value + and not OPENID_END_SESSION_ENDPOINT.value + ): provider_list = ", ".join(configured_providers) log.warning( f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!" ) log.warning( - f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality." + f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint," + f" or set OPENID_END_SESSION_ENDPOINT to a custom logout URL to fix logout functionality." ) @@ -1960,7 +1977,7 @@ Suggest 3-5 relevant follow-up questions or prompts that the user might naturall - Only suggest follow-ups that make sense given the chat content and do not repeat what was already covered. - If the conversation is very short or not specific, suggest more general (but relevant) follow-ups the user might ask. - Use the conversation's primary language; default to English if multilingual. -- Response must be a JSON array of strings, no extra text or formatting. +- Response must be a JSON object with a "follow_ups" key containing an array of strings, no extra text or formatting. ### Output: JSON format: { "follow_ups": ["Question 1?", "Question 2?", "Question 3?"] } ### Chat History: @@ -2290,20 +2307,36 @@ CODE_INTERPRETER_BLOCKED_MODULES = [ ] DEFAULT_CODE_INTERPRETER_PROMPT = """ -#### Tools Available +#### Code Interpreter -1. **Code Interpreter**: `` - - You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response. - - The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**. - - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. - - When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code. - - When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user. - - After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.** - - If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary. - - **If a link to an image, audio, or any file is provided in markdown format in the output, ALWAYS regurgitate word for word, explicitly display it as part of the response to ensure the user can access it easily, do NOT change the link.** - - All responses should be communicated in the chat's primary language, ensuring seamless understanding. If the chat is multilingual, default to English for clarity. +You have access to a Python code interpreter via: `` -Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user.""" +- The Python shell runs directly in the user's browser for fast execution of analysis, calculations, or problem-solving. Use it in this response. +- You can use a wide array of libraries for data manipulation, visualization, API calls, or any computational task. Think outside the box and harness Python's full potential. +- **You must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. +- Do NOT use triple backticks (```py ... ```) inside the XML tags — that is markdown formatting, not executable Python code. +- **Always print meaningful outputs** (results, tables, summaries, visuals). Avoid implicit outputs; use explicit print statements. +- After obtaining output, **provide a concise analysis, interpretation, or next steps** to help the user understand the findings. +- If results are unclear or unexpected, refine the code and re-execute. Iterate until you deliver meaningful insights. +- **If a link to an image, audio, or any file appears in the output, display it exactly as-is** in your response so the user can access it. Do not modify the link. +- Respond in the chat's primary language. Default to English if multilingual. + +Ensure the code interpreter is effectively utilized to achieve the highest-quality analysis for the user.""" + +# Appended to the code interpreter prompt only when engine is pyodide (not jupyter) +CODE_INTERPRETER_PYODIDE_PROMPT = """ + +##### Pyodide Environment + +- This Python environment runs via Pyodide in the browser. **Do not install packages** — `pip install`, `subprocess`, and `micropip.install()` are not available. +- If a required library is unavailable, use an alternative approach with available modules. Do not attempt to install anything. + +##### Persistent File System + +- User-uploaded files are available at `/mnt/uploads/`. When the user asks you to work with their files, read from this directory. +- You can also write output files to `/mnt/uploads/` so the user can access and download them from the file browser. +- The file system persists across code executions within the same session. +- Use `import os; os.listdir('/mnt/uploads')` to discover available files.""" #################################### @@ -2337,6 +2370,81 @@ if VECTOR_DB == "chroma": CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) + +# MariaDB Vector (mariadb-vector) +MARIADB_VECTOR_DB_URL = os.environ.get("MARIADB_VECTOR_DB_URL", "").strip() + +MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( + os.environ.get("MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536").strip() + or "1536" +) + +# Distance strategy: +# - cosine => vec_distance_cosine(...) +# - euclidean => vec_distance_euclidean(...) +MARIADB_VECTOR_DISTANCE_STRATEGY = ( + os.environ.get("MARIADB_VECTOR_DISTANCE_STRATEGY", "cosine").strip().lower() +) + +# HNSW M parameter (MariaDB VECTOR INDEX ... M=) +MARIADB_VECTOR_INDEX_M = int( + os.environ.get("MARIADB_VECTOR_INDEX_M", "8").strip() or "8" +) + +# Pooling (MariaDB-Vector) +MARIADB_VECTOR_POOL_SIZE = os.environ.get("MARIADB_VECTOR_POOL_SIZE", None) + +if MARIADB_VECTOR_POOL_SIZE != None: + try: + MARIADB_VECTOR_POOL_SIZE = int(MARIADB_VECTOR_POOL_SIZE) + except Exception: + MARIADB_VECTOR_POOL_SIZE = None + +MARIADB_VECTOR_POOL_MAX_OVERFLOW = os.environ.get("MARIADB_VECTOR_POOL_MAX_OVERFLOW", 0) + +if MARIADB_VECTOR_POOL_MAX_OVERFLOW == "": + MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0 +else: + try: + MARIADB_VECTOR_POOL_MAX_OVERFLOW = int(MARIADB_VECTOR_POOL_MAX_OVERFLOW) + except Exception: + MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0 + +MARIADB_VECTOR_POOL_TIMEOUT = os.environ.get("MARIADB_VECTOR_POOL_TIMEOUT", 30) + +if MARIADB_VECTOR_POOL_TIMEOUT == "": + MARIADB_VECTOR_POOL_TIMEOUT = 30 +else: + try: + MARIADB_VECTOR_POOL_TIMEOUT = int(MARIADB_VECTOR_POOL_TIMEOUT) + except Exception: + MARIADB_VECTOR_POOL_TIMEOUT = 30 + +MARIADB_VECTOR_POOL_RECYCLE = os.environ.get("MARIADB_VECTOR_POOL_RECYCLE", 3600) + +if MARIADB_VECTOR_POOL_RECYCLE == "": + MARIADB_VECTOR_POOL_RECYCLE = 3600 +else: + try: + MARIADB_VECTOR_POOL_RECYCLE = int(MARIADB_VECTOR_POOL_RECYCLE) + except Exception: + MARIADB_VECTOR_POOL_RECYCLE = 3600 + +ENABLE_MARIADB_VECTOR = True +if VECTOR_DB == "mariadb-vector": + if not MARIADB_VECTOR_DB_URL: + ENABLE_MARIADB_VECTOR = False + else: + try: + parsed = urlparse(MARIADB_VECTOR_DB_URL) + scheme = (parsed.scheme or "").lower() + # Require official driver so VECTOR binds as float32 bytes correctly + if scheme != "mariadb+mariadbconnector": + ENABLE_MARIADB_VECTOR = False + except Exception: + ENABLE_MARIADB_VECTOR = False + + # Milvus MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_DB = os.environ.get("MILVUS_DB", "default") diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 61f2960124..30334d9c08 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -788,6 +788,16 @@ try: except ValueError: WEBSOCKET_SERVER_PING_INTERVAL = 25 +WEBSOCKET_EVENT_CALLER_TIMEOUT = os.environ.get("WEBSOCKET_EVENT_CALLER_TIMEOUT", "") + +if WEBSOCKET_EVENT_CALLER_TIMEOUT == "": + WEBSOCKET_EVENT_CALLER_TIMEOUT = None +else: + try: + WEBSOCKET_EVENT_CALLER_TIMEOUT = int(WEBSOCKET_EVENT_CALLER_TIMEOUT) + except ValueError: + WEBSOCKET_EVENT_CALLER_TIMEOUT = 300 + REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true" diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index 6050e37fa3..afc2e76621 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -102,11 +102,30 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"): conn.execute(f"PRAGMA key = '{database_password}'") return conn - engine = create_engine( - "sqlite://", # Dummy URL since we're using creator - creator=create_sqlcipher_connection, - echo=False, - ) + # The dummy "sqlite://" URL would cause SQLAlchemy to auto-select + # SingletonThreadPool, which non-deterministically closes in-use + # connections when thread count exceeds pool_size, leading to segfaults + # in the native sqlcipher3 C library. Use NullPool by default for safety, + # or QueuePool if DATABASE_POOL_SIZE is explicitly configured. + if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0: + engine = create_engine( + "sqlite://", + creator=create_sqlcipher_connection, + pool_size=DATABASE_POOL_SIZE, + max_overflow=DATABASE_POOL_MAX_OVERFLOW, + pool_timeout=DATABASE_POOL_TIMEOUT, + pool_recycle=DATABASE_POOL_RECYCLE, + pool_pre_ping=True, + poolclass=QueuePool, + echo=False, + ) + else: + engine = create_engine( + "sqlite://", + creator=create_sqlcipher_connection, + poolclass=NullPool, + echo=False, + ) log.info("Connected to encrypted SQLite database using SQLCipher") diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 6267de79c8..6f4d867e5e 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1402,46 +1402,52 @@ app.add_middleware(RedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) -class APIKeyRestrictionMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - auth_header = request.headers.get("Authorization") - token = None +class APIKeyRestrictionMiddleware: + def __init__(self, app): + self.app = app - if auth_header: - parts = auth_header.split(" ", 1) - if len(parts) == 2: - token = parts[1] + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + request = Request(scope) + auth_header = request.headers.get("Authorization") + token = None - # Only apply restrictions if an sk- API key is used - if token and token.startswith("sk-"): - # Check if restrictions are enabled - if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: - allowed_paths = [ - path.strip() - for path in str( - request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS - ).split(",") - if path.strip() - ] + if auth_header: + parts = auth_header.split(" ", 1) + if len(parts) == 2: + token = parts[1] - request_path = request.url.path + # Only apply restrictions if an sk- API key is used + if token and token.startswith("sk-"): + # Check if restrictions are enabled + if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: + allowed_paths = [ + path.strip() + for path in str( + app.state.config.API_KEYS_ALLOWED_ENDPOINTS + ).split(",") + if path.strip() + ] - # Match exact path or prefix path - is_allowed = any( - request_path == allowed or request_path.startswith(allowed + "/") - for allowed in allowed_paths - ) + request_path = request.url.path - if not is_allowed: - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "detail": "API key not allowed to access this endpoint." - }, + # Match exact path or prefix path + is_allowed = any( + request_path == allowed + or request_path.startswith(allowed + "/") + for allowed in allowed_paths ) - response = await call_next(request) - return response + if not is_allowed: + await JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "API key not allowed to access this endpoint." + }, + )(scope, receive, send) + return + + await self.app(scope, receive, send) app.add_middleware(APIKeyRestrictionMiddleware) @@ -1730,8 +1736,8 @@ async def chat_completion( } # Check base model existence for custom models - if model_info_params.get("base_model_id"): - base_model_id = model_info_params.get("base_model_id") + if model_info and model_info.base_model_id: + base_model_id = model_info.base_model_id if base_model_id not in request.app.state.MODELS: if ENABLE_CUSTOM_MODEL_FALLBACK: default_models = ( @@ -1862,7 +1868,7 @@ async def chat_completion( "model": model_id, }, ) - except: + except Exception: pass ctx = build_chat_response_context( @@ -1909,7 +1915,7 @@ async def chat_completion( {"type": "chat:tasks:cancel"}, ) - except: + except Exception: pass finally: try: @@ -2191,6 +2197,7 @@ async def get_app_config(request: Request): "user_count": user_count, "code": { "engine": app.state.config.CODE_EXECUTION_ENGINE, + "interpreter_engine": app.state.config.CODE_INTERPRETER_ENGINE, }, "audio": { "tts": { diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py index 567f7d673a..aa9b0a4c26 100644 --- a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -21,6 +21,39 @@ down_revision: Union[str, None] = "374d2f66af06" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +BATCH_SIZE = 5000 + + +def _flush_batch(conn, table, batch): + """ + Insert a batch of messages, falling back to row-by-row on error. + + Tries a single bulk insert first (fast path). If that fails (e.g. due to + a duplicate key), falls back to individual inserts wrapped in savepoints + so the rest of the batch can still succeed. + """ + savepoint = conn.begin_nested() + try: + conn.execute(sa.insert(table), batch) + savepoint.commit() + return len(batch), 0 + except Exception: + savepoint.rollback() + # Batch failed - insert one-by-one to isolate the bad row(s) + inserted = 0 + failed = 0 + for msg in batch: + sp = conn.begin_nested() + try: + conn.execute(sa.insert(table).values(**msg)) + sp.commit() + inserted += 1 + except Exception as e: + sp.rollback() + failed += 1 + log.warning(f"Failed to insert message {msg['id']}: {e}") + return inserted, failed + def upgrade() -> None: # Step 1: Create table @@ -88,18 +121,21 @@ def upgrade() -> None: sa.column("updated_at", sa.BigInteger()), ) - # Fetch all chats (excluding shared chats which have user_id starting with 'shared-') - chats = conn.execute( - sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where( - ~chat_table.c.user_id.like("shared-%") - ) - ).fetchall() + # Stream rows instead of loading all into memory: + # - yield_per: fetches rows in chunks via cursor.fetchmany() (all backends) + # - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite) + result = conn.execute( + sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat) + .where(~chat_table.c.user_id.like("shared-%")) + .execution_options(yield_per=1000, stream_results=True) + ) now = int(time.time()) - messages_inserted = 0 - messages_failed = 0 + messages_batch = [] + total_inserted = 0 + total_failed = 0 - for chat_row in chats: + for chat_row in result: chat_id = chat_row[0] user_id = chat_row[1] chat_data = chat_row[2] @@ -139,39 +175,49 @@ def upgrade() -> None: if timestamp < 1577836800 or timestamp > now + 86400: timestamp = now - # Use savepoint to allow individual insert failures without aborting transaction - savepoint = conn.begin_nested() - try: - conn.execute( - sa.insert(chat_message_table).values( - id=f"{chat_id}-{message_id}", - chat_id=chat_id, - user_id=user_id, - role=role, - parent_id=message.get("parentId"), - content=message.get("content"), - output=message.get("output"), - model_id=message.get("model"), - files=message.get("files"), - sources=message.get("sources"), - embeds=message.get("embeds"), - done=message.get("done", True), - status_history=message.get("statusHistory"), - error=message.get("error"), - created_at=timestamp, - updated_at=timestamp, - ) + messages_batch.append( + { + "id": f"{chat_id}-{message_id}", + "chat_id": chat_id, + "user_id": user_id, + "role": role, + "parent_id": message.get("parentId"), + "content": message.get("content"), + "output": message.get("output"), + "model_id": message.get("model"), + "files": message.get("files"), + "sources": message.get("sources"), + "embeds": message.get("embeds"), + "done": message.get("done", True), + "status_history": message.get("statusHistory"), + "error": message.get("error"), + "usage": message.get("usage"), + "created_at": timestamp, + "updated_at": timestamp, + } + ) + + # Flush batch when full + if len(messages_batch) >= BATCH_SIZE: + inserted, failed = _flush_batch( + conn, chat_message_table, messages_batch ) - savepoint.commit() - messages_inserted += 1 - except Exception as e: - savepoint.rollback() - messages_failed += 1 - log.warning(f"Failed to insert message {message_id}: {e}") - continue + total_inserted += inserted + total_failed += failed + if total_inserted % 50000 < BATCH_SIZE: + log.info( + f"Migration progress: {total_inserted} messages inserted..." + ) + messages_batch.clear() + + # Flush remaining messages + if messages_batch: + inserted, failed = _flush_batch(conn, chat_message_table, messages_batch) + total_inserted += inserted + total_failed += failed log.info( - f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)" + f"Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)" ) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index e6e932be12..00609ce7f0 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -292,9 +292,11 @@ class ChatMessageTable: query = query.filter(ChatMessage.created_at <= end_date) # Group by chat_id and order by most recent message in each chat + # Secondary sort on chat_id ensures deterministic pagination + # (prevents duplicates across pages when timestamps tie) chat_ids = ( query.group_by(ChatMessage.chat_id) - .order_by(func.max(ChatMessage.created_at).desc()) + .order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id) .offset(skip) .limit(limit) .all() @@ -418,11 +420,13 @@ class ChatMessageTable: self, start_date: Optional[int] = None, end_date: Optional[int] = None, + group_id: Optional[str] = None, db: Optional[Session] = None, ) -> dict[str, dict]: """Aggregate token usage by user using database-level aggregation.""" with get_db_context(db) as db: from sqlalchemy import func, cast, Integer + from open_webui.models.groups import GroupMember dialect = db.bind.dialect.name @@ -462,6 +466,13 @@ class ChatMessageTable: query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) + if group_id: + group_users = ( + db.query(GroupMember.user_id) + .filter(GroupMember.group_id == group_id) + .subquery() + ) + query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.user_id).all() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 8c6eb830b5..78aa4d3ded 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -734,13 +734,13 @@ class ChatTable: raise ValueError("Invalid order_by field") if direction.lower() == "asc": - query = query.order_by(getattr(Chat, order_by).asc()) + query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == "desc": - query = query.order_by(getattr(Chat, order_by).desc()) + query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: raise ValueError("Invalid direction for ordering") else: - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) query = query.with_entities( Chat.id, Chat.title, Chat.updated_at, Chat.created_at @@ -793,13 +793,13 @@ class ChatTable: raise ValueError("Invalid order_by field") if direction.lower() == "asc": - query = query.order_by(getattr(Chat, order_by).asc()) + query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == "desc": - query = query.order_by(getattr(Chat, order_by).desc()) + query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: raise ValueError("Invalid direction for ordering") else: - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) # Select only the columns needed for SharedChatResponse # to avoid loading the heavy chat JSON blob @@ -854,13 +854,13 @@ class ChatTable: if order_by and direction and getattr(Chat, order_by): if direction.lower() == "asc": - query = query.order_by(getattr(Chat, order_by).asc()) + query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == "desc": - query = query.order_by(getattr(Chat, order_by).desc()) + query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: raise ValueError("Invalid direction for ordering") else: - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) if skip: query = query.offset(skip) @@ -892,7 +892,7 @@ class ChatTable: if not include_archived: query = query.filter_by(archived=False) - query = query.order_by(Chat.updated_at.desc()).with_entities( + query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities( Chat.id, Chat.title, Chat.updated_at, Chat.created_at ) @@ -1039,14 +1039,18 @@ class ChatTable: if order_by and direction: if hasattr(Chat, order_by): if direction.lower() == "asc": - query = query.order_by(getattr(Chat, order_by).asc()) + query = query.order_by( + getattr(Chat, order_by).asc(), Chat.id + ) elif direction.lower() == "desc": - query = query.order_by(getattr(Chat, order_by).desc()) + query = query.order_by( + getattr(Chat, order_by).desc(), Chat.id + ) else: - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) else: - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) total = query.count() @@ -1188,7 +1192,7 @@ class ChatTable: if folder_ids: query = query.filter(Chat.folder_id.in_(folder_ids)) - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) # Check if the database dialect is either 'sqlite' or 'postgresql' dialect_name = db.bind.dialect.name @@ -1309,7 +1313,7 @@ class ChatTable: query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) query = query.filter_by(archived=False) - query = query.order_by(Chat.updated_at.desc()) + query = query.order_by(Chat.updated_at.desc(), Chat.id) if skip: query = query.offset(skip) diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 09060f9bde..84dd43f5e8 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -4,6 +4,7 @@ from typing import Optional from sqlalchemy.orm import Session from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.utils.misc import sanitize_metadata from pydantic import BaseModel, ConfigDict, model_validator from sqlalchemy import BigInteger, Column, String, Text, JSON @@ -127,9 +128,16 @@ class FilesTable: self, user_id: str, form_data: FileForm, db: Optional[Session] = None ) -> Optional[FileModel]: with get_db_context(db) as db: + file_data = form_data.model_dump() + + # Sanitize meta to remove non-JSON-serializable objects + # (e.g. callable tool functions, MCP client instances from middleware) + if file_data.get("meta"): + file_data["meta"] = sanitize_metadata(file_data["meta"]) + file = FileModel( **{ - **form_data.model_dump(), + **file_data, "user_id": user_id, "created_at": int(time.time()), "updated_at": int(time.time()), @@ -289,7 +297,7 @@ class FilesTable: db: Optional database session. Returns: - List of matching FileModel objects, ordered by updated_at descending. + List of matching FileModel objects, ordered by created_at descending. """ with get_db_context(db) as db: query = db.query(File) @@ -303,7 +311,7 @@ class FilesTable: return [ FileModel.model_validate(file) - for file in query.order_by(File.updated_at.desc()) + for file in query.order_by(File.created_at.desc(), File.id.desc()) .offset(skip) .limit(limit) .all() diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 24a872bcc4..b491b831f2 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -71,6 +71,7 @@ class FolderForm(BaseModel): name: str data: Optional[dict] = None meta: Optional[dict] = None + parent_id: Optional[str] = None model_config = ConfigDict(extra="allow") diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index fdbfac5e7c..18916315e6 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -308,6 +308,28 @@ class FunctionsTable: log.exception(f"Error getting function valves by id {id}: {e}") return None + def get_function_valves_by_ids( + self, ids: list[str], db: Optional[Session] = None + ) -> dict[str, dict]: + """ + Batch fetch valves for multiple functions in a single query. + Returns a dict mapping function_id -> valves dict. + Functions without valves are mapped to {}. + """ + if not ids: + return {} + try: + with get_db_context(db) as db: + functions = ( + db.query(Function.id, Function.valves) + .filter(Function.id.in_(ids)) + .all() + ) + return {f.id: (f.valves if f.valves else {}) for f in functions} + except Exception as e: + log.exception(f"Error batch-fetching function valves: {e}") + return {} + def update_function_valves_by_id( self, id: str, valves: dict, db: Optional[Session] = None ) -> Optional[FunctionValves]: diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 51ecd627ac..27c633833e 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -86,6 +86,9 @@ known_source_ext = [ "hs", "lhs", "json", + "yaml", + "yml", + "toml", ] diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 86b4acc06d..5030a6eecb 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -590,7 +590,9 @@ def generate_openai_batch_embeddings( if "data" in data: return [elem["embedding"] for elem in data["data"]] else: - raise "Something went wrong :/" + raise ValueError( + "Unexpected OpenAI embeddings response: missing 'data' key" + ) except Exception as e: log.exception(f"Error generating openai batch embeddings: {e}") return None @@ -767,7 +769,9 @@ def generate_ollama_batch_embeddings( if "embeddings" in data: return data["embeddings"] else: - raise "Something went wrong :/" + raise ValueError( + "Unexpected Ollama embeddings response: missing 'embeddings' key" + ) except Exception as e: log.exception(f"Error generating ollama batch embeddings: {e}") return None diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index e209453f5c..dfb02ec029 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from elasticsearch import Elasticsearch, BadRequestError from typing import Optional import ssl diff --git a/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py new file mode 100644 index 0000000000..dfcfb3da59 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py @@ -0,0 +1,593 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + +from __future__ import annotations + +import array +import json +import logging +import math +import re +import sys +from contextlib import contextmanager +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import create_engine +from sqlalchemy.pool import NullPool, QueuePool + +from open_webui.config import ( + MARIADB_VECTOR_DB_URL, + MARIADB_VECTOR_DISTANCE_STRATEGY, + MARIADB_VECTOR_INDEX_M, + MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH, + MARIADB_VECTOR_POOL_SIZE, + MARIADB_VECTOR_POOL_MAX_OVERFLOW, + MARIADB_VECTOR_POOL_TIMEOUT, + MARIADB_VECTOR_POOL_RECYCLE, +) +from open_webui.retrieval.vector.main import ( + GetResult, + SearchResult, + VectorDBBase, + VectorItem, +) +from open_webui.retrieval.vector.utils import process_metadata + +log = logging.getLogger(__name__) + +VECTOR_LENGTH = int(MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH) + + +def _embedding_to_f32_bytes(vec: List[float]) -> bytes: + """ + Convert a Python float vector into the binary payload expected by MariaDB VECTOR. + + MariaDB Vector expects the vector argument to be bound as a little-endian float32 + byte sequence. We use array('f') to avoid a numpy dependency and byteswap on + big-endian platforms for portability. + """ + a = array.array("f", [float(x) for x in vec]) # float32 + if sys.byteorder != "little": + a.byteswap() + return a.tobytes() + + +def _safe_json(v: Any) -> Dict[str, Any]: + """ + Normalize a potentially JSON-like value into a Python dict. + + Accepts: + - dict: returned as-is + - str / bytes: parsed as JSON if possible + - None / other types: returns {} + """ + if v is None: + return {} + if isinstance(v, dict): + return v + if isinstance(v, (bytes, bytearray)): + try: + v = v.decode("utf-8") + except Exception: + return {} + if isinstance(v, str): + try: + j = json.loads(v) + return j if isinstance(j, dict) else {} + except Exception: + return {} + return {} + + +class MariaDBVectorClient(VectorDBBase): + """ + MariaDB + MariaDB Vector backend using DBAPI cursor parameter binding. + + IMPORTANT: + - Intended for: mariadb+mariadbconnector://... (official MariaDB driver). + - Uses qmark ("?") params and binds vectors as float32 bytes. + - Uses binary binding for BOTH inserts/updates and distance computations. + """ + + def __init__( + self, + db_url: Optional[str] = None, + vector_length: int = VECTOR_LENGTH, + distance_strategy: str = MARIADB_VECTOR_DISTANCE_STRATEGY, + index_m: int = MARIADB_VECTOR_INDEX_M, + ) -> None: + """ + Initialize a MariaDB Vector-backed VectorDBBase implementation. + + Validates URL scheme/driver requirements, ensures schema exists, and guards + against dimension mismatch with an existing VECTOR(n) column. + """ + self.db_url = (db_url or MARIADB_VECTOR_DB_URL).strip() + self.vector_length = int(vector_length) + self.distance_strategy = (distance_strategy or "cosine").strip().lower() + self.index_m = int(index_m) + + if self.distance_strategy not in {"cosine", "euclidean"}: + raise ValueError("distance_strategy must be 'cosine' or 'euclidean'") + + if not self.db_url.lower().startswith("mariadb+mariadbconnector://"): + raise ValueError( + "MariaDBVectorClient requires mariadb+mariadbconnector:// (official MariaDB driver) " + "to ensure qmark paramstyle and correct VECTOR binding." + ) + + if isinstance(MARIADB_VECTOR_POOL_SIZE, int): + if MARIADB_VECTOR_POOL_SIZE > 0: + self.engine = create_engine( + self.db_url, + pool_size=MARIADB_VECTOR_POOL_SIZE, + max_overflow=MARIADB_VECTOR_POOL_MAX_OVERFLOW, + pool_timeout=MARIADB_VECTOR_POOL_TIMEOUT, + pool_recycle=MARIADB_VECTOR_POOL_RECYCLE, + pool_pre_ping=True, + poolclass=QueuePool, + ) + else: + self.engine = create_engine( + self.db_url, pool_pre_ping=True, poolclass=NullPool + ) + else: + self.engine = create_engine(self.db_url, pool_pre_ping=True) + self._init_schema() + self._check_vector_length() + + @contextmanager + def _connect(self): + """ + Yield a context-managed DBAPI connection (SQLAlchemy raw_connection()). + + Callers can use: + with self._connect() as conn: + with conn.cursor() as cur: + ... + """ + conn = self.engine.raw_connection() + try: + yield conn + finally: + try: + conn.close() + except Exception: + pass + + def _init_schema(self) -> None: + """ + Create the backing table and vector index if they do not exist. + + Uses a PK definition compatible with MariaDB Vector's VECTOR INDEX key-size constraints. + """ + with self._connect() as conn: + with conn.cursor() as cur: + try: + dist = self.distance_strategy + cur.execute(f""" + CREATE TABLE IF NOT EXISTS document_chunk ( + -- MariaDB Vector requires the table PRIMARY KEY used with a VECTOR INDEX to be <= 256 bytes. + -- VARCHAR has internal length/metadata overhead, so VARCHAR(255) can exceed the 256-byte limit. + -- We use VARCHAR(254) to stay safely under the limit, and force ASCII (1 byte/char) so the byte + -- size is predictable (avoid utf8mb4 where a "255 char" key could be up to 1020 bytes). + -- ascii_bin gives bytewise, case-sensitive comparisons for stable ID matching. + id VARCHAR(254) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY, + embedding VECTOR({self.vector_length}) NOT NULL, + collection_name VARCHAR(255) NOT NULL, + text LONGTEXT NULL, + vmetadata JSON NULL, + VECTOR INDEX (embedding) M={self.index_m} DISTANCE={dist}, + INDEX idx_document_chunk_collection_name (collection_name) + ) ENGINE=InnoDB; + """) + conn.commit() + except Exception as e: + conn.rollback() + log.exception(f"Error during database initialization: {e}") + raise + + def _check_vector_length(self) -> None: + """ + Validate that the existing VECTOR column dimension matches this client's configured dimension. + + Dimension guard: if table already exists with + a different VECTOR(n), refuse to silently mismatch. + """ + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute("SHOW CREATE TABLE document_chunk") + row = cur.fetchone() + if not row or len(row) < 2: + return + ddl = row[1] + m = re.search(r"vector\\((\\d+)\\)", ddl, flags=re.IGNORECASE) + if not m: + return + existing = int(m.group(1)) + if existing != int(self.vector_length): + raise Exception( + f"VECTOR_LENGTH {self.vector_length} does not match existing vector column dimension {existing}. " + "Cannot change vector size after initialization without migrating the data." + ) + + def adjust_vector_length(self, vector: List[float]) -> List[float]: + """ + Pad or truncate a vector to match `self.vector_length`. + """ + n = len(vector) + if n < self.vector_length: + return vector + [0.0] * (self.vector_length - n) + if n > self.vector_length: + return vector[: self.vector_length] + return vector + + def _dist_fn(self) -> str: + """ + Return the MariaDB Vector distance function name for the configured strategy. + """ + return ( + "vec_distance_cosine" + if self.distance_strategy == "cosine" + else "vec_distance_euclidean" + ) + + def _score_from_dist(self, dist: float) -> float: + """ + Convert a DB distance value into a normalized score in (0, 1]. + + - cosine: score ~= 1 - cosine_distance, clamped to [0, 1] + - euclidean: score = 1 / (1 + dist) + """ + if self.distance_strategy == "cosine": + score = 1.0 - dist + if score < 0.0: + score = 0.0 + if score > 1.0: + score = 1.0 + return score + return 1.0 / (1.0 + max(0.0, dist)) + + def _build_filter_sql_qmark(self, expr: Any) -> Tuple[str, List[Any]]: + """ + Build a WHERE-clause fragment and qmark params from a minimal Mongo-like filter. + + Supported forms: + - {"field": "v"} + - {"field": {"$in": ["a","b"]}} + - {"$and": [ ... ]} + - {"$or": [ ... ]} + """ + if not expr or not isinstance(expr, dict): + return "", [] + + if "$and" in expr: + parts: List[str] = [] + params: List[Any] = [] + for e in expr.get("$and") or []: + s, p = self._build_filter_sql_qmark(e) + if s: + parts.append(s) + params.extend(p) + return ("(" + " AND ".join(parts) + ")") if parts else "", params + + if "$or" in expr: + parts: List[str] = [] + params: List[Any] = [] + for e in expr.get("$or") or []: + s, p = self._build_filter_sql_qmark(e) + if s: + parts.append(s) + params.extend(p) + return ("(" + " OR ".join(parts) + ")") if parts else "", params + + clauses: List[str] = [] + params: List[Any] = [] + for key, value in expr.items(): + if key.startswith("$"): + continue + json_expr = f"JSON_UNQUOTE(JSON_EXTRACT(vmetadata, '$.{key}'))" + if isinstance(value, dict) and "$in" in value: + vals = [str(v) for v in (value.get("$in") or [])] + if not vals: + clauses.append("0=1") + continue + ors = [] + for v in vals: + ors.append(f"{json_expr} = ?") + params.append(v) + clauses.append("(" + " OR ".join(ors) + ")") + else: + clauses.append(f"{json_expr} = ?") + params.append(str(value)) + return ("(" + " AND ".join(clauses) + ")") if clauses else "", params + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Insert items into the given collection (best-effort, ignores duplicates). + + Uses executemany() with binary VECTOR binding for high-throughput ingestion. + """ + if not items: + return + with self._connect() as conn: + with conn.cursor() as cur: + try: + sql = """ + INSERT IGNORE INTO document_chunk + (id, embedding, collection_name, text, vmetadata) + VALUES + (?, ?, ?, ?, ?) + """ + params: List[Tuple[Any, ...]] = [] + for item in items: + v = self.adjust_vector_length(item["vector"]) + emb = _embedding_to_f32_bytes(v) + meta = process_metadata(item.get("metadata") or {}) + params.append( + ( + item["id"], + emb, + collection_name, + item.get("text"), + json.dumps(meta), + ) + ) + cur.executemany(sql, params) + conn.commit() + except Exception as e: + conn.rollback() + log.exception(f"Error during insert: {e}") + raise + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Insert or update items in the given collection by primary key. + + Uses executemany() and updates embedding/text/metadata on conflicts. + """ + if not items: + return + with self._connect() as conn: + with conn.cursor() as cur: + try: + sql = """ + INSERT INTO document_chunk + (id, embedding, collection_name, text, vmetadata) + VALUES + (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + embedding = VALUES(embedding), + collection_name = VALUES(collection_name), + text = VALUES(text), + vmetadata = VALUES(vmetadata) + """ + params: List[Tuple[Any, ...]] = [] + for item in items: + v = self.adjust_vector_length(item["vector"]) + emb = _embedding_to_f32_bytes(v) + meta = process_metadata(item.get("metadata") or {}) + params.append( + ( + item["id"], + emb, + collection_name, + item.get("text"), + json.dumps(meta), + ) + ) + cur.executemany(sql, params) + conn.commit() + except Exception as e: + conn.rollback() + log.exception(f"Error during upsert: {e}") + raise + + def search( + self, + collection_name: str, + vectors: List[List[float]], + filter: Optional[Dict[str, Any]] = None, + limit: int = 10, + ) -> Optional[SearchResult]: + """ + Perform a vector similarity search. + + Args: + collection_name: Logical collection partition key. + vectors: One or more query vectors. + filter: Optional metadata filter (Mongo-like subset). + limit: Top-k per query vector. + + Returns a SearchResult where distances are normalized scores (higher is better). + """ + if not vectors: + return None + + dist_fn = self._dist_fn() + ids: List[List[str]] = [[] for _ in vectors] + distances: List[List[float]] = [[] for _ in vectors] + documents: List[List[str]] = [[] for _ in vectors] + metadatas: List[List[Any]] = [[] for _ in vectors] + + try: + with self._connect() as conn: + with conn.cursor() as cur: + fsql, fparams = self._build_filter_sql_qmark(filter or {}) + where = "collection_name = ?" + base_params: List[Any] = [collection_name] + if fsql: + where = where + " AND " + fsql + base_params.extend(fparams) + + sql = f""" + SELECT + id, + text, + vmetadata, + {dist_fn}(embedding, ?) AS dist + FROM document_chunk + WHERE {where} + ORDER BY dist ASC + LIMIT ? + """ + + for q_idx, q in enumerate(vectors): + qv = self.adjust_vector_length(q) + qbin = _embedding_to_f32_bytes(qv) + params = [qbin] + list(base_params) + [int(limit)] + cur.execute(sql, params) + rows = cur.fetchall() + + for r in rows: + rid, rtext, rmeta, rdist = r[0], r[1], r[2], r[3] + ids[q_idx].append(str(rid)) + try: + dist = float(rdist) if rdist is not None else 1.0 + except Exception: + dist = 1.0 + if math.isnan(dist) or math.isinf(dist): + dist = 1.0 + distances[q_idx].append(self._score_from_dist(dist)) + documents[q_idx].append(rtext) + metadatas[q_idx].append(_safe_json(rmeta)) + + return SearchResult( + ids=ids, + distances=distances, + documents=documents, + metadatas=metadatas, + ) + except Exception as e: + log.exception(f"[MARIADB_VECTOR] search() failed: {e}") + return None + + def query( + self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None + ) -> Optional[GetResult]: + """ + Retrieve documents by metadata filter (non-vector query). + """ + with self._connect() as conn: + with conn.cursor() as cur: + fsql, fparams = self._build_filter_sql_qmark(filter or {}) + where = "collection_name = ?" + params: List[Any] = [collection_name] + if fsql: + where = where + " AND " + fsql + params.extend(fparams) + sql = f"SELECT id, text, vmetadata FROM document_chunk WHERE {where}" + if limit is not None: + sql += " LIMIT ?" + params.append(int(limit)) + cur.execute(sql, params) + rows = cur.fetchall() + if not rows: + return None + ids = [[str(r[0]) for r in rows]] + documents = [[r[1] for r in rows]] + metadatas = [[_safe_json(r[2]) for r in rows]] + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + + def get( + self, collection_name: str, limit: Optional[int] = None + ) -> Optional[GetResult]: + """ + Retrieve documents in a collection without filtering (optionally limited). + """ + with self._connect() as conn: + with conn.cursor() as cur: + sql = "SELECT id, text, vmetadata FROM document_chunk WHERE collection_name = ?" + params: List[Any] = [collection_name] + if limit is not None: + sql += " LIMIT ?" + params.append(int(limit)) + cur.execute(sql, params) + rows = cur.fetchall() + if not rows: + return None + ids = [[str(r[0]) for r in rows]] + documents = [[r[1] for r in rows]] + metadatas = [[_safe_json(r[2]) for r in rows]] + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Delete rows from a collection by id list and/or metadata filter. + + If both are provided, they are combined with AND semantics. + """ + with self._connect() as conn: + with conn.cursor() as cur: + try: + where = ["collection_name = ?"] + params: List[Any] = [collection_name] + + if ids: + ph = ", ".join(["?"] * len(ids)) + where.append(f"id IN ({ph})") + params.extend(ids) + + if filter: + fsql, fparams = self._build_filter_sql_qmark(filter) + if fsql: + where.append(fsql) + params.extend(fparams) + + sql = "DELETE FROM document_chunk WHERE " + " AND ".join(where) + cur.execute(sql, params) + conn.commit() + except Exception as e: + conn.rollback() + log.exception(f"Error during delete: {e}") + raise + + def reset(self) -> None: + """ + Truncate the vector table (drops all collections). + """ + with self._connect() as conn: + with conn.cursor() as cur: + try: + cur.execute("TRUNCATE TABLE document_chunk") + conn.commit() + except Exception as e: + conn.rollback() + log.exception(f"Error during reset: {e}") + raise + + def has_collection(self, collection_name: str) -> bool: + """ + Return True if the collection contains at least one row, else False. + """ + try: + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM document_chunk WHERE collection_name = ? LIMIT 1", + (collection_name,), + ) + return cur.fetchone() is not None + except Exception: + return False + + def delete_collection(self, collection_name: str) -> None: + """ + Delete all rows in a collection. + """ + self.delete(collection_name) + + def close(self) -> None: + """ + Dispose the underlying SQLAlchemy engine. + """ + try: + self.engine.dispose() + except Exception as e: + log.exception(f"Error during dispose the underlying SQLAlchemy engine: {e}") diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 35cf6b3829..4dcf76c64d 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from pymilvus import MilvusClient as Client from pymilvus import FieldSchema, DataType from pymilvus import connections, Collection diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index c58189b2a3..0ecbac15d2 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + import logging from typing import Optional, Tuple, List, Dict, Any diff --git a/backend/open_webui/retrieval/vector/dbs/opengauss.py b/backend/open_webui/retrieval/vector/dbs/opengauss.py index 7d4f9ea092..679847a1d4 100644 --- a/backend/open_webui/retrieval/vector/dbs/opengauss.py +++ b/backend/open_webui/retrieval/vector/dbs/opengauss.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from typing import Optional, List, Dict, Any import logging import re diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index ed5a931c68..3ad82d7442 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from opensearchpy import OpenSearch from opensearchpy.helpers import bulk from typing import Optional diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index f4258c9eff..10428de384 100644 --- a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -1,4 +1,6 @@ """ +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. + Oracle 23ai Vector Database Client - Fixed Version # .env diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index 156894bc9e..27bc50b70e 100644 --- a/backend/open_webui/retrieval/vector/dbs/pinecone.py +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from typing import Optional, List, Dict, Any, Union import logging import time # for measuring elapsed time diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index d42984e1d6..e774bb875f 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from typing import Optional import logging from urllib.parse import urlparse diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index f87f85a23b..5ad2ac6929 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + import logging from typing import Optional, Tuple, List, Dict, Any from urllib.parse import urlparse diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py index 96e487f111..1a30e04e55 100644 --- a/backend/open_webui/retrieval/vector/dbs/s3vector.py +++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + from open_webui.retrieval.vector.utils import process_metadata from open_webui.retrieval.vector.main import ( VectorDBBase, @@ -61,6 +65,11 @@ class S3VectorClient(VectorDBBase): dataType=data_type, dimension=dimension, distanceMetric=distance_metric, + metadataConfiguration={ + "nonFilterableMetadataKeys": [ + "text", + ] + }, ) log.info( f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})" diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py index dcc648c788..c9b09ad638 100644 --- a/backend/open_webui/retrieval/vector/dbs/weaviate.py +++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py @@ -1,3 +1,7 @@ +""" +NOTE: This vector database integration is community-supported and maintained on a best-effort basis. +""" + import weaviate import re import uuid diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py index 68595fb595..d92b335864 100644 --- a/backend/open_webui/retrieval/vector/factory.py +++ b/backend/open_webui/retrieval/vector/factory.py @@ -57,6 +57,12 @@ class Vector: from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient return OpenGaussClient() + case VectorType.MARIADB_VECTOR: + from open_webui.retrieval.vector.dbs.mariadb_vector import ( + MariaDBVectorClient, + ) + + return MariaDBVectorClient() case VectorType.ELASTICSEARCH: from open_webui.retrieval.vector.dbs.elasticsearch import ( ElasticsearchClient, diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py index de20133fce..df9453aa3e 100644 --- a/backend/open_webui/retrieval/vector/type.py +++ b/backend/open_webui/retrieval/vector/type.py @@ -3,6 +3,7 @@ from enum import StrEnum class VectorType(StrEnum): MILVUS = "milvus" + MARIADB_VECTOR = "mariadb-vector" QDRANT = "qdrant" CHROMA = "chroma" PINECONE = "pinecone" diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 61aec66332..9579845a49 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -86,7 +86,7 @@ async def get_user_analytics( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) token_usage = ChatMessages.get_token_usage_by_user( - start_date=start_date, end_date=end_date, db=db + start_date=start_date, end_date=end_date, group_id=group_id, db=db ) # Get user info for top users @@ -278,7 +278,7 @@ class ModelChatsResponse(BaseModel): total: int -@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse) +@router.get("/models/{model_id:path}/chats", response_model=ModelChatsResponse) async def get_model_chats( model_id: str, start_date: Optional[int] = Query(None), @@ -367,7 +367,7 @@ class ModelOverviewResponse(BaseModel): tags: list[TagEntry] -@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse) +@router.get("/models/{model_id:path}/overview", response_model=ModelOverviewResponse) async def get_model_overview( model_id: str, days: int = Query(30, description="Number of days of history (0 for all)"), diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 3156078326..a1f3ac523f 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -492,7 +492,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus" base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL language = request.app.state.config.TTS_VOICE - locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:1]) + locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:2]) output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT try: @@ -852,17 +852,34 @@ def transcription_handler(request, file_path, metadata, user=None): except requests.exceptions.RequestException as e: log.exception(e) detail = None + status_code = getattr(r, "status_code", 500) if r else 500 try: if r is not None and r.status_code != 200: res = r.json() - if "error" in res: + # Azure returns {"code": "...", "message": "...", "innerError": {...}} + if "code" in res and "message" in res: + azure_code = res.get("innerError", {}).get("code", res["code"]) + user_facing_codes = { + "EmptyAudioFile", + "AudioLengthLimitExceeded", + "NoLanguageIdentified", + "MultipleLanguagesIdentified", + } + if azure_code in user_facing_codes: + detail = res["message"] + else: + log.error( + f"Azure STT error [{azure_code}]: {res['message']}" + ) + detail = "An error occurred during transcription." + elif "error" in res: detail = f"External: {res['error'].get('message', '')}" except Exception: detail = f"External: {e}" raise HTTPException( - status_code=getattr(r, "status_code", 500) if r else 500, + status_code=status_code, detail=detail if detail else "Open WebUI: Server Connection Error", ) @@ -1087,6 +1104,8 @@ def transcribe( for future in futures: try: results.append(future.result()) + except HTTPException: + raise except Exception as transcribe_exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1226,6 +1245,8 @@ def transcription( "filename": os.path.basename(file_path), } + except HTTPException: + raise except Exception as e: log.exception(e) @@ -1234,6 +1255,8 @@ def transcription( detail="Transcription failed.", ) + except HTTPException: + raise except Exception as e: log.exception(e) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index f3c4fbc6d4..f5a60c59e6 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -46,6 +46,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response, JSONResponse from open_webui.config import ( OPENID_PROVIDER_URL, + OPENID_END_SESSION_ENDPOINT, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP, ENABLE_PASSWORD_AUTH, @@ -824,6 +825,19 @@ async def signout( response.delete_cookie("oauth_session_id") session = OAuthSessions.get_session_by_id(oauth_session_id, db=db) + + # If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect + # there directly instead of attempting OIDC discovery. + if OPENID_END_SESSION_ENDPOINT.value: + return JSONResponse( + status_code=200, + content={ + "status": True, + "redirect_url": OPENID_END_SESSION_ENDPOINT.value, + }, + headers=response.headers, + ) + oauth_server_metadata_url = ( request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 6e3271d7ec..5268ea657d 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -583,12 +583,36 @@ def update_file_data_content_by_id( request, ProcessFileForm(file_id=id, content=form_data.content), user=user, + db=db, ) file = Files.get_file_by_id(id=id, db=db) except Exception as e: log.exception(e) log.error(f"Error processing file: {file.id}") + # Propagate content change to all knowledge collections referencing + # this file. Without this the old embeddings remain in the knowledge + # collection and RAG returns both stale and current data (#20558). + knowledges = Knowledges.get_knowledges_by_file_id(id, db=db) + for knowledge in knowledges: + try: + # Remove old embeddings for this file from the KB collection + VECTOR_DB_CLIENT.delete( + collection_name=knowledge.id, filter={"file_id": id} + ) + # Re-add from the now-updated file-{file_id} collection + process_file( + request, + ProcessFileForm(file_id=id, collection_name=knowledge.id), + user=user, + db=db, + ) + except Exception as e: + log.warning( + f"Failed to update knowledge {knowledge.id} after " + f"content change for file {id}: {e}" + ) + return {"content": file.data.get("content", "")} else: raise HTTPException( diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index ab0326c882..f6269e7b72 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -119,7 +119,7 @@ def create_folder( db: Session = Depends(get_session), ): folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - None, user.id, form_data.name, db=db + form_data.parent_id, user.id, form_data.name, db=db ) if folder: @@ -129,7 +129,9 @@ def create_folder( ) try: - folder = Folders.insert_new_folder(user.id, form_data, db=db) + folder = Folders.insert_new_folder( + user.id, form_data, form_data.parent_id, db=db + ) return folder except Exception as e: log.exception(e) @@ -317,7 +319,9 @@ async def delete_folder_by_id( folder = folders.pop() if folder: try: - folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db) + folder_ids = Folders.delete_folder_by_id_and_user_id( + folder.id, user.id, db=db + ) for folder_id in folder_ids: if delete_contents: diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 580717987c..14ff55b109 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1176,7 +1176,7 @@ async def embeddings( class GenerateCompletionForm(BaseModel): model: str - prompt: str + prompt: Optional[str] = None suffix: Optional[str] = None images: Optional[list[str]] = None format: Optional[Union[dict, str]] = None @@ -1747,7 +1747,9 @@ async def download_file_stream( yield f"data: {json.dumps(res)}\n\n" else: - raise "Ollama: Could not create blob, Please try again." + raise RuntimeError( + "Ollama: Could not create blob, Please try again." + ) # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf" diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index ebedd3027d..fa1b77a09c 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -92,8 +92,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models): json=request_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: - payload = await response.json() response.raise_for_status() + payload = await response.json() except aiohttp.ClientResponseError as e: res = ( await response.json() @@ -145,8 +145,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models): json=request_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: - payload = await response.json() response.raise_for_status() + payload = await response.json() except aiohttp.ClientResponseError as e: try: res = ( diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 1c10680a88..8394c84164 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -733,10 +733,10 @@ class ConfigForm(BaseModel): CHUNK_OVERLAP: Optional[int] = None # File upload settings - FILE_MAX_SIZE: Optional[int] = None - FILE_MAX_COUNT: Optional[int] = None - FILE_IMAGE_COMPRESSION_WIDTH: Optional[int] = None - FILE_IMAGE_COMPRESSION_HEIGHT: Optional[int] = None + FILE_MAX_SIZE: Optional[Union[int, str]] = None + FILE_MAX_COUNT: Optional[Union[int, str]] = None + FILE_IMAGE_COMPRESSION_WIDTH: Optional[Union[int, str]] = None + FILE_IMAGE_COMPRESSION_HEIGHT: Optional[Union[int, str]] = None ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None # Integration settings @@ -1069,26 +1069,29 @@ async def update_rag_config( ) # File upload settings - request.app.state.config.FILE_MAX_SIZE = ( - form_data.FILE_MAX_SIZE - if form_data.FILE_MAX_SIZE is not None - else request.app.state.config.FILE_MAX_SIZE - ) - request.app.state.config.FILE_MAX_COUNT = ( - form_data.FILE_MAX_COUNT - if form_data.FILE_MAX_COUNT is not None - else request.app.state.config.FILE_MAX_COUNT - ) - request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = ( - form_data.FILE_IMAGE_COMPRESSION_WIDTH - if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None - else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH - ) - request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = ( - form_data.FILE_IMAGE_COMPRESSION_HEIGHT - if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None - else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT - ) + # Empty string means "clear to None" (unlimited/no compression), + # None means "don't change", int means "set to this value" + if form_data.FILE_MAX_SIZE is not None: + request.app.state.config.FILE_MAX_SIZE = ( + None if form_data.FILE_MAX_SIZE == "" else form_data.FILE_MAX_SIZE + ) + if form_data.FILE_MAX_COUNT is not None: + request.app.state.config.FILE_MAX_COUNT = ( + None if form_data.FILE_MAX_COUNT == "" else form_data.FILE_MAX_COUNT + ) + if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None: + request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = ( + None + if form_data.FILE_IMAGE_COMPRESSION_WIDTH == "" + else form_data.FILE_IMAGE_COMPRESSION_WIDTH + ) + if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None: + request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = ( + None + if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == "" + else form_data.FILE_IMAGE_COMPRESSION_HEIGHT + ) + request.app.state.config.ALLOWED_FILE_EXTENSIONS = ( form_data.ALLOWED_FILE_EXTENSIONS if form_data.ALLOWED_FILE_EXTENSIONS is not None diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index f2594ae5d0..e18b561321 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -19,7 +19,7 @@ from open_webui.models.skills import ( ) from open_webui.models.access_grants import AccessGrants from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_access, has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 254545a273..fe03816865 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -8,13 +8,14 @@ Routes: import logging import aiohttp -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, Request, Response, WebSocket from fastapi.responses import JSONResponse, StreamingResponse from starlette.background import BackgroundTask from open_webui.utils.auth import get_verified_user from open_webui.utils.access_control import has_connection_access from open_webui.models.groups import Groups +from open_webui.models.users import Users log = logging.getLogger(__name__) @@ -149,3 +150,155 @@ async def proxy_terminal( return JSONResponse( {"error": f"Terminal proxy error: {error}"}, status_code=502 ) + + +# --------------------------------------------------------------------------- +# WebSocket proxy for interactive terminal sessions +# --------------------------------------------------------------------------- + + +async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): + """Authenticate a WebSocket via first-message auth and resolve the terminal server. + + The client must send ``{"type": "auth", "token": ""}`` as its first + message after connecting. + + Returns ``(user, connection)`` on success, or ``None`` after closing *ws* + with an appropriate error code. + """ + import asyncio + import json + from open_webui.utils.auth import decode_token + + # First-message authentication + try: + raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) + payload = json.loads(raw) + if payload.get("type") != "auth": + await ws.close(code=4001, reason="Expected auth message") + return None + token = payload.get("token", "") + data = decode_token(token) + if data is None or "id" not in data: + await ws.close(code=4001, reason="Invalid token") + return None + user = Users.get_user_by_id(data["id"]) + if user is None: + await ws.close(code=4001, reason="User not found") + return None + except (asyncio.TimeoutError, json.JSONDecodeError): + await ws.close(code=4001, reason="Auth timeout or invalid payload") + return None + except Exception: + await ws.close(code=4001, reason="Invalid token") + return None + + # Resolve terminal server + connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get("id") == server_id), None) + + if connection is None: + await ws.close(code=4004, reason="Terminal server not found") + return None + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + await ws.close(code=4003, reason="Access denied") + return None + + return user, connection + + +@router.websocket("/{server_id}/api/terminals/{session_id}") +async def ws_terminal( + ws: WebSocket, + server_id: str, + session_id: str, +): + """Proxy an interactive WebSocket terminal session to a terminal server. + + Uses first-message auth: the client sends ``{"type": "auth", "token": ""}`` + as its first message. The proxy validates the JWT, then connects to the + upstream terminal server and authenticates with the server's API key. + """ + await ws.accept() + + result = await _resolve_authenticated_connection(ws, server_id) + if result is None: + return + user, connection = result + + base_url = (connection.get("url") or "").rstrip("/") + if not base_url: + await ws.close(code=4003, reason="Terminal server URL not configured") + return + + # Build upstream WebSocket URL (no token in URL) + ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://") + + auth_type = connection.get("auth_type", "bearer") + upstream_params = {} + # For orchestrator-backed servers, pass user_id + upstream_params["user_id"] = user.id + + import urllib.parse + + upstream_url = f"{ws_base}/api/terminals/{session_id}" + if upstream_params: + upstream_url += f"?{urllib.parse.urlencode(upstream_params)}" + + session = aiohttp.ClientSession() + try: + async with session.ws_connect(upstream_url) as upstream: + import asyncio + import json as _json + + # First-message auth to upstream terminal server + auth_type = connection.get("auth_type", "bearer") + if auth_type == "bearer": + key = connection.get("key", "") + await upstream.send_str(_json.dumps({"type": "auth", "token": key})) + + async def _client_to_upstream(): + """Forward client → upstream.""" + try: + while True: + msg = await ws.receive() + if msg["type"] == "websocket.disconnect": + break + elif "bytes" in msg and msg["bytes"]: + await upstream.send_bytes(msg["bytes"]) + elif "text" in msg and msg["text"]: + await upstream.send_str(msg["text"]) + except Exception: + pass + + async def _upstream_to_client(): + """Forward upstream → client.""" + try: + async for msg in upstream: + if msg.type == aiohttp.WSMsgType.BINARY: + await ws.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_text(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.ERROR, + ): + break + except Exception: + pass + + await asyncio.gather( + _client_to_upstream(), + _upstream_to_client(), + return_exceptions=True, + ) + except Exception as e: + log.exception("Terminal WebSocket proxy error: %s", e) + finally: + await session.close() + try: + await ws.close() + except Exception: + pass diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index e5b2daad51..351c491bdc 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -30,7 +30,11 @@ from open_webui.utils.plugin import ( ) from open_webui.utils.tools import get_tool_specs from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_access, has_permission +from open_webui.utils.access_control import ( + has_permission, + has_access, + filter_allowed_access_grants, +) from open_webui.utils.tools import get_tool_servers from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 758b530c92..3070959332 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -37,6 +37,7 @@ from open_webui.env import ( WEBSOCKET_SERVER_PING_INTERVAL, WEBSOCKET_SERVER_LOGGING, WEBSOCKET_SERVER_ENGINEIO_LOGGING, + WEBSOCKET_EVENT_CALLER_TIMEOUT, ) from open_webui.utils.auth import decode_token from open_webui.socket.utils import RedisDict, RedisLock, YdocManager @@ -918,6 +919,7 @@ def get_event_call(request_info): "data": event_data, }, to=request_info["session_id"], + timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, ) return response diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index a59df3813f..b438759e10 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -155,8 +155,7 @@ async def search_web( ) -> str: """ Search the public web for information. Best for current events, external references, - or topics not covered in internal documents. If knowledge base tools are available, - consider checking those first for internal information. + or topics not covered in internal documents. :param query: The search query to look up :param count: Number of results to return (default: 5) @@ -250,11 +249,13 @@ async def generate_image( # Persist files to DB if chat context is available if __chat_id__ and __message_id__ and images: - image_files = Chats.add_message_files_by_id_and_message_id( + db_files = Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, image_files, ) + if db_files is not None: + image_files = db_files # Emit the images to the UI if event emitter is available if __event_emitter__ and image_files: @@ -315,11 +316,13 @@ async def edit_image( # Persist files to DB if chat context is available if __chat_id__ and __message_id__ and images: - image_files = Chats.add_message_files_by_id_and_message_id( + db_files = Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, image_files, ) + if db_files is not None: + image_files = db_files # Emit the images to the UI if event emitter is available if __event_emitter__ and image_files: @@ -426,6 +429,9 @@ async def execute_code( "session_id": ( __metadata__.get("session_id") if __metadata__ else None ), + "files": ( + __metadata__.get("files", []) if __metadata__ else [] + ), }, } ) @@ -1831,7 +1837,7 @@ async def query_knowledge_files( elif item_type == "file": # Individual file - use file-{id} as collection name file = Files.get_file_by_id(item_id) - if file and (user_role == "admin" or file.user_id == user_id): + if file: collection_names.append(f"file-{item_id}") elif item_type == "note": diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 232bd20d60..a228bbd6a8 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -163,8 +163,8 @@ def has_connection_access( based on ``config.access_grants`` within the connection dict. - Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed - - Empty / missing access_grants → allowed for all users - - Otherwise → delegates to ``has_access`` + - Missing, None, or empty access_grants → private, admin-only + - access_grants has entries → delegates to ``has_access`` """ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL @@ -175,9 +175,6 @@ def has_connection_access( user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} access_grants = (connection.get("config") or {}).get("access_grants", []) - if not access_grants: - return True - return has_access(user.id, "read", access_grants, user_group_ids) diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index 11c06f14ad..e3d52b0f55 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -7,6 +7,7 @@ from open_webui.models.knowledge import Knowledges from open_webui.models.channels import Channels from open_webui.models.chats import Chats from open_webui.models.groups import Groups +from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants log = logging.getLogger(__name__) @@ -21,6 +22,7 @@ def has_access_to_file( """ Check if a user has the specified access to a file through any of: - Knowledge bases (ownership or access grants) + - Shared workspace models that attach the file directly - Channels the user is a member of - Shared chats @@ -72,4 +74,15 @@ def has_access_to_file( if chats: return True + # Check if the file is directly attached to a shared workspace model + for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db): + knowledge_items = getattr(model.meta, "knowledge", None) or [] + for item in knowledge_items: + if ( + isinstance(item, dict) + and item.get("type") == "file" + and item.get("id") == file.id + ): + return True + return False diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 55f55ab033..8bfed7d391 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1,3 +1,4 @@ +import copy import time import logging import sys @@ -91,6 +92,7 @@ from open_webui.utils.misc import ( get_last_user_message_item, get_last_assistant_message, get_system_message, + replace_system_message_content, prepend_to_first_user_message_content, convert_logit_bias_input_to_json, get_content_from_message, @@ -118,6 +120,7 @@ from open_webui.config import ( DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, DEFAULT_CODE_INTERPRETER_PROMPT, + CODE_INTERPRETER_PYODIDE_PROMPT, CODE_INTERPRETER_BLOCKED_MODULES, ) from open_webui.env import ( @@ -159,6 +162,55 @@ def output_id(prefix: str) -> str: return f"{prefix}_{uuid4().hex[:24]}" +def _split_tool_calls( + tool_calls: list[dict], +) -> list[dict]: + """Expand tool calls whose arguments contain multiple back-to-back JSON objects. + + Some models (e.g. GPT-5.4) send multiple complete JSON argument objects + under the same tool call index, producing concatenated invalid JSON like: + '{"query":"A","count":5}{"query":"B","count":5}' + + Each such tool call is split into separate entries so each gets executed + independently. Single-object arguments pass through unchanged. + """ + + def split_json_objects(raw: str) -> list[str]: + decoder = json.JSONDecoder() + results = [] + position = 0 + + while position < len(raw): + while position < len(raw) and raw[position].isspace(): + position += 1 + if position >= len(raw): + break + try: + _, end = decoder.raw_decode(raw, position) + results.append(raw[position:end].strip()) + position = end + except json.JSONDecodeError: + return [raw] + + return results or [raw] + + expanded = [] + for tool_call in tool_calls: + arguments = tool_call.get("function", {}).get("arguments", "") + split_arguments = split_json_objects(arguments) + + if len(split_arguments) <= 1: + expanded.append(tool_call) + else: + for argument in split_arguments: + cloned = copy.deepcopy(tool_call) + cloned["id"] = f"call_{uuid4().hex[:24]}" + cloned["function"]["arguments"] = argument + expanded.append(cloned) + + return expanded + + def get_citation_source_from_tool_result( tool_name: str, tool_params: dict, tool_result: str, tool_id: str = "" ) -> list[dict]: @@ -172,6 +224,9 @@ def get_citation_source_from_tool_result( Returns a list of sources (usually one, but query_knowledge_files may return multiple). """ + _EXPECTS_LIST = {"search_web", "query_knowledge_files"} + _EXPECTS_DICT = {"view_knowledge_file"} + try: try: tool_result = json.loads(tool_result) @@ -180,6 +235,12 @@ def get_citation_source_from_tool_result( if isinstance(tool_result, dict) and "error" in tool_result: return [] + # Validate tool_result type based on what the branch expects + if tool_name in _EXPECTS_LIST and not isinstance(tool_result, list): + return [] + elif tool_name in _EXPECTS_DICT and not isinstance(tool_result, dict): + return [] + if tool_name == "search_web": # Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}] results = tool_result @@ -375,9 +436,9 @@ def serialize_output(output: list) -> str: result_item = tool_outputs.get(call_id) if result_item: result_text = "" - for output in result_item.get("output", []): - if "text" in output: - output_text = output.get("text", "") + for result_output in result_item.get("output", []): + if "text" in result_output: + output_text = result_output.get("text", "") result_text += ( str(output_text) if not isinstance(output_text, str) @@ -829,6 +890,32 @@ def handle_responses_streaming_event( return current_output, None +def get_source_context( + sources: list, source_ids: dict = None, include_content: bool = True +) -> str: + """ + Build tag context string from citation sources. + """ + context_string = "" + if source_ids is None: + source_ids = {} + for source in sources: + for doc, meta in zip(source.get("document", []), source.get("metadata", [])): + source_id = ( + meta.get("source") or source.get("source", {}).get("id") or "N/A" + ) + if source_id not in source_ids: + source_ids[source_id] = len(source_ids) + 1 + src_name = source.get("source", {}).get("name") + body = doc if include_content else "" + context_string += ( + f'{body}\n" + ) + return context_string + + def apply_source_context_to_messages( request: Request, messages: list, @@ -847,39 +934,21 @@ def apply_source_context_to_messages( if not sources or not user_message: return messages - context_string = "" - citation_idx = {} + context = get_source_context(sources, include_content=include_content) - for source in sources: - for doc, meta in zip(source.get("document", []), source.get("metadata", [])): - src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A" - if src_id not in citation_idx: - citation_idx[src_id] = len(citation_idx) + 1 - src_name = source.get("source", {}).get("name") - body = doc if include_content else "" - context_string += ( - f'{body}\n" - ) - - context_string = context_string.strip() - if not context_string: + context = context.strip() + if not context: return messages if RAG_SYSTEM_CONTEXT: return add_or_update_system_message( - rag_template( - request.app.state.config.RAG_TEMPLATE, context_string, user_message - ), + rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), messages, append=True, ) else: return add_or_update_user_message( - rag_template( - request.app.state.config.RAG_TEMPLATE, context_string, user_message - ), + rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), messages, append=False, ) @@ -1052,16 +1121,16 @@ async def terminal_event_handler( """Emit terminal:* events for Open Terminal tools. - display_file → emits 'terminal:display_file' to open the file preview. - - write_file → emits 'terminal:write_file' to silently refresh the file browser. + - write_file / replace_file_content → emits 'terminal:write_file' to refresh. + - run_command → emits 'terminal:run_command' with cwd to refresh if relevant. """ if not event_emitter: return - path = tool_function_params.get("path", "") - if not path: - return - if tool_function_name == "display_file": + path = tool_function_params.get("path", "") + if not path: + return # Only emit if the file actually exists parsed = tool_result if isinstance(parsed, str): @@ -1078,13 +1147,23 @@ async def terminal_event_handler( "data": {"path": path}, } ) - elif tool_function_name == "write_file": + elif tool_function_name in ("write_file", "replace_file_content"): + path = tool_function_params.get("path", "") + if not path: + return await event_emitter( { "type": f"terminal:{tool_function_name}", "data": {"path": path}, } ) + elif tool_function_name == "run_command": + await event_emitter( + { + "type": "terminal:run_command", + "data": {}, + } + ) async def chat_completion_tools_handler( @@ -2170,10 +2249,15 @@ async def process_chat_payload(request, form_data, user, metadata, model): folder.data["system_prompt"], form_data, metadata, user ) if "files" in folder.data: - form_data["files"] = [ - *folder.data["files"], - *form_data.get("files", []), - ] + if metadata.get("params", {}).get("function_calling") != "native": + form_data["files"] = [ + *folder.data["files"], + *form_data.get("files", []), + ] + else: + # Native FC: skip RAG injection, builtin tools + # will read folder knowledge from metadata. + metadata["folder_knowledge"] = folder.data["files"] # Model "Knowledge" handling user_message = get_last_user_message(form_data["messages"]) @@ -2283,18 +2367,35 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) if "code_interpreter" in features and features["code_interpreter"]: + engine = getattr( + request.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide" + ) + # Skip XML-tag prompt injection when native FC is enabled — # execute_code will be injected as a builtin tool instead if metadata.get("params", {}).get("function_calling") != "native": + prompt = ( + request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE + if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != "" + else DEFAULT_CODE_INTERPRETER_PROMPT + ) + + # Append filesystem awareness only for pyodide engine + if engine != "jupyter": + prompt += CODE_INTERPRETER_PYODIDE_PROMPT + form_data["messages"] = add_or_update_user_message( - ( - request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE - if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE - != "" - else DEFAULT_CODE_INTERPRETER_PROMPT - ), + prompt, form_data["messages"], ) + else: + # Native FC: tool docstring can't be dynamic, so inject + # filesystem context into messages for pyodide engine + if engine != "jupyter": + form_data["messages"] = add_or_update_user_message( + CODE_INTERPRETER_PYODIDE_PROMPT, + form_data["messages"], + ) tool_ids = form_data.pop("tool_ids", None) terminal_id = form_data.pop("terminal_id", None) @@ -2644,6 +2745,16 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception as e: log.exception(e) + # Save the pre-RAG message state so the native tool call loop can + # restore to the true original (before file-source injection) rather + # than a snapshot that already has the RAG template baked in. + system_message = get_system_message(form_data["messages"]) + metadata["system_prompt"] = ( + get_content_from_message(system_message) if system_message else None + ) + metadata["user_prompt"] = get_last_user_message(form_data["messages"]) + metadata["sources"] = sources[:] if sources else [] + # If context is not empty, insert it into the messages if sources and prompt: form_data["messages"] = apply_source_context_to_messages( @@ -3082,6 +3193,8 @@ async def non_streaming_chat_response_handler(response, ctx): ) # Save message in the database + usage = normalize_usage(response_data.get("usage", {}) or {}) + Chats.upsert_message_to_chat_by_id_and_message_id( metadata["chat_id"], metadata["message_id"], @@ -3089,6 +3202,7 @@ async def non_streaming_chat_response_handler(response, ctx): "role": "assistant", "content": content, "output": response_output, + **({"usage": usage} if usage else {}), }, ) @@ -3718,7 +3832,7 @@ async def streaming_chat_response_handler(response, ctx): if delta_name: current_response_tool_call[ "function" - ]["name"] += delta_name + ]["name"] = delta_name if delta_arguments: current_response_tool_call[ @@ -3766,14 +3880,17 @@ async def streaming_chat_response_handler(response, ctx): delta.get("images", []), request, metadata, user ) if image_urls: + image_file_list = [ + {"type": "image", "url": url} + for url in image_urls + ] message_files = Chats.add_message_files_by_id_and_message_id( metadata["chat_id"], metadata["message_id"], - [ - {"type": "image", "url": url} - for url in image_urls - ], + image_file_list, ) + if message_files is None: + message_files = image_file_list await event_emitter( { @@ -4078,7 +4195,7 @@ async def streaming_chat_response_handler(response, ctx): reasoning_item["status"] = "completed" if response_tool_calls: - tool_calls.append(response_tool_calls) + tool_calls.append(_split_tool_calls(response_tool_calls)) if response.background: await response.background() @@ -4090,6 +4207,23 @@ async def streaming_chat_response_handler(response, ctx): all_tool_call_sources = [] # Accumulated sources across all iterations user_message = get_last_user_message(form_data["messages"]) + # Check if citations are enabled for this model + citations_enabled = ( + model.get("info", {}).get("meta", {}).get("capabilities") or {} + ).get("citations", True) + + # Use the pre-RAG system content captured before the + # initial file-source injection in process_chat_payload. + # This ensures restore truly undoes the RAG template. + original_system_content = metadata.get("system_prompt") + if original_system_content is None: + original_system_message = get_system_message(form_data["messages"]) + original_system_content = ( + get_content_from_message(original_system_message) + if original_system_message + else None + ) + while ( len(tool_calls) > 0 and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES @@ -4136,25 +4270,26 @@ async def streaming_chat_response_handler(response, ctx): tool_args = tool_call.get("function", {}).get("arguments", "{}") tool_function_params = {} - try: - # json.loads cannot be used because some models do not produce valid JSON - tool_function_params = ast.literal_eval(tool_args) - except Exception as e: - log.debug(e) - # Fallback to JSON parsing + if tool_args and tool_args.strip(): try: - tool_function_params = json.loads(tool_args) + # json.loads cannot be used because some models do not produce valid JSON + tool_function_params = ast.literal_eval(tool_args) except Exception as e: - log.error( - f"Error parsing tool call arguments: {tool_args}" - ) - results.append( - { - "tool_call_id": tool_call_id, - "content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.", - } - ) - continue + log.debug(e) + # Fallback to JSON parsing + try: + tool_function_params = json.loads(tool_args) + except Exception as e: + log.error( + f"Error parsing tool call arguments: {tool_args}" + ) + results.append( + { + "tool_call_id": tool_call_id, + "content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.", + } + ) + continue # Ensure arguments are valid JSON for downstream LLM integrations log.debug( @@ -4244,7 +4379,8 @@ async def streaming_chat_response_handler(response, ctx): # Extract citation sources from tool results if ( - tool_function_name + citations_enabled + and tool_function_name in [ "search_web", "fetch_url", @@ -4334,27 +4470,62 @@ async def streaming_chat_response_handler(response, ctx): } ) - # Emit citation sources for UI display - for source in tool_call_sources: - await event_emitter({"type": "source", "data": source}) + # Emit citation sources to the frontend for display + if citations_enabled: + for source in tool_call_sources: + await event_emitter({"type": "source", "data": source}) - # Apply source context to messages for model - # Use metadata_only=True to avoid duplicating content - # that is already in the tool result message. - all_tool_call_sources.extend(tool_call_sources) - if all_tool_call_sources and user_message: - # Restore original user message before re-applying to avoid recursive nesting - set_last_user_message_content( - user_message, form_data["messages"] - ) - form_data["messages"] = apply_source_context_to_messages( - request, - form_data["messages"], - all_tool_call_sources, - user_message, - include_content=False, - ) - tool_call_sources.clear() + # Apply tool source context to messages for the model. + # Restoring to pre-RAG original prevents duplicating + # the RAG template across file and tool sources. + all_tool_call_sources.extend(tool_call_sources) + if all_tool_call_sources and user_message: + # Restore pre-RAG message state before re-applying + # to prevent RAG template duplication. + original_user_message = ( + metadata.get("user_prompt") or user_message + ) + set_last_user_message_content( + original_user_message, + form_data["messages"], + ) + replace_system_message_content( + original_system_content or "", + form_data["messages"], + ) + + # Build context: file sources with content, + # tool sources as citation markers only. + source_ids = {} + source_context = get_source_context( + metadata.get("sources", []), source_ids + ) + get_source_context( + all_tool_call_sources, + source_ids, + include_content=False, + ) + source_context = source_context.strip() + if source_context: + rag_content = rag_template( + request.app.state.config.RAG_TEMPLATE, + source_context, + user_message, + ) + if RAG_SYSTEM_CONTEXT: + form_data["messages"] = ( + add_or_update_system_message( + rag_content, + form_data["messages"], + append=True, + ) + ) + else: + form_data["messages"] = add_or_update_user_message( + rag_content, + form_data["messages"], + append=False, + ) + tool_call_sources.clear() await event_emitter( { @@ -4456,6 +4627,7 @@ async def streaming_chat_response_handler(response, ctx): "session_id": metadata.get( "session_id", None ), + "files": metadata.get("files", []), }, } ) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 35e19d70ce..643648862b 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -566,6 +566,53 @@ def sanitize_data_for_db(obj): return obj +def sanitize_metadata(metadata: dict) -> dict: + """ + Return a JSON-safe copy of a metadata dict for database storage. + + The middleware metadata accumulates non-serializable Python objects + (e.g. callable tool functions, MCP client instances) that cause + PostgreSQL JSON inserts to fail. This helper strips those out while + preserving the primitive data needed for file-to-chat linking. + """ + if not isinstance(metadata, dict): + return metadata + + def _sanitize(obj): + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + if isinstance(obj, dict): + return { + k: _sanitize(v) + for k, v in obj.items() + if not callable(v) and _is_serializable(v) + } + if isinstance(obj, list): + return [ + _sanitize(v) for v in obj if not callable(v) and _is_serializable(v) + ] + if callable(obj): + return None + # Last resort: try to see if it's serializable + try: + json.dumps(obj) + return obj + except (TypeError, ValueError): + return None + + def _is_serializable(obj): + """Quick check whether a value can survive JSON serialization.""" + if isinstance(obj, (str, int, float, bool, type(None), dict, list)): + return True + try: + json.dumps(obj) + return True + except (TypeError, ValueError): + return False + + return _sanitize(metadata) + + def extract_folders_after_data_docs(path): # Convert the path to a Path object if it's not already path = Path(path) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 6c82b4aa98..108cd0b4b0 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -149,63 +149,64 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ] custom_models = Models.get_all_models() + + # Single O(1) lookup: Ollama base names first, then exact IDs (exact wins). + base_model_lookup = {} + for model in models: + if model.get("owned_by") == "ollama": + base_model_lookup.setdefault(model["id"].split(":")[0], model) + base_model_lookup[model["id"]] = model + + existing_ids = {m["id"] for m in models} + for custom_model in custom_models: if custom_model.base_model_id is None: - # Applied directly to a base model - for model in models: - if custom_model.id == model["id"] or ( - model.get("owned_by") == "ollama" - and custom_model.id - == model["id"].split(":")[ - 0 - ] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b') - ): - if custom_model.is_active: - model["name"] = custom_model.name - model["info"] = custom_model.model_dump() + # Override applied directly to a base model (shares the same ID) + model = base_model_lookup.get(custom_model.id) - # Set action_ids and filter_ids - action_ids = [] - filter_ids = [] + if model: + if custom_model.is_active: + model["name"] = custom_model.name + model["info"] = custom_model.model_dump() - if "info" in model: - if "meta" in model["info"]: - action_ids.extend( - model["info"]["meta"].get("actionIds", []) - ) - filter_ids.extend( - model["info"]["meta"].get("filterIds", []) - ) + action_ids = [] + filter_ids = [] - if "params" in model["info"]: - # Remove params to avoid exposing sensitive info - del model["info"]["params"] + if "info" in model: + if "meta" in model["info"]: + action_ids.extend( + model["info"]["meta"].get("actionIds", []) + ) + filter_ids.extend( + model["info"]["meta"].get("filterIds", []) + ) - model["action_ids"] = action_ids - model["filter_ids"] = filter_ids - else: - models.remove(model) + if "params" in model["info"]: + del model["info"]["params"] + + model["action_ids"] = action_ids + model["filter_ids"] = filter_ids + else: + models.remove(model) + + elif custom_model.is_active: + if custom_model.id in existing_ids: + continue - elif custom_model.is_active and ( - custom_model.id not in [model["id"] for model in models] - ): - # Custom model based on a base model owned_by = "openai" connection_type = None - pipe = None - for m in models: - if ( - custom_model.base_model_id == m["id"] - or custom_model.base_model_id == m["id"].split(":")[0] - ): - owned_by = m.get("owned_by", "unknown") - if "pipe" in m: - pipe = m["pipe"] - - connection_type = m.get("connection_type", None) - break + base_model = base_model_lookup.get(custom_model.base_model_id) + if base_model is None: + base_model = base_model_lookup.get( + custom_model.base_model_id.split(":")[0] + ) + if base_model: + owned_by = base_model.get("owned_by", "unknown") + if "pipe" in base_model: + pipe = base_model["pipe"] + connection_type = base_model.get("connection_type", None) model = { "id": f"{custom_model.id}", @@ -331,11 +332,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) elif meta.get(key) is None: meta[key] = copy.deepcopy(value) + # Batch-fetch all function valves in one query to avoid N+1 DB hits + # inside get_action_priority (previously called per action × per model). + all_function_valves = Functions.get_function_valves_by_ids(list(all_function_ids)) + def get_action_priority(action_id): try: function_module = request.app.state.FUNCTIONS.get(action_id) if function_module and hasattr(function_module, "Valves"): - valves_db = Functions.get_function_valves_by_id(action_id) + valves_db = all_function_valves.get(action_id) valves = function_module.Valves(**(valves_db if valves_db else {})) return getattr(valves, "priority", 0) except Exception: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 284917d22c..636ad957d7 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -36,6 +36,7 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_SIGNUP, + OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, OAUTH_MERGE_ACCOUNTS_BY_EMAIL, OAUTH_PROVIDERS, ENABLE_OAUTH_ROLE_MANAGEMENT, @@ -113,6 +114,9 @@ log = logging.getLogger(__name__) auth_manager_config = AppConfig() auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP +auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = ( + OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE +) auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT @@ -616,7 +620,7 @@ class OAuthClientManager: payload = json.loads(response_text) error = payload.get("error") error_description = payload.get("error_description", "") - except: + except Exception: pass else: error_description = response_text @@ -787,6 +791,16 @@ class OAuthClientManager: if hasattr(client, "client_secret") and client.client_secret: refresh_data["client_secret"] = client.client_secret + # Add scope if available in client kwargs (some providers require it on refresh) + if ( + hasattr(client, "client_kwargs") + and client.client_kwargs.get("scope") + and getattr( + self.app.state.config, "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", False + ) + ): + refresh_data["scope"] = client.client_kwargs["scope"] + # Make refresh request async with aiohttp.ClientSession(trust_env=True) as session_http: async with session_http.post( @@ -1081,6 +1095,14 @@ class OAuthManager: if hasattr(client, "client_secret") and client.client_secret: refresh_data["client_secret"] = client.client_secret + # Add scope if available in client kwargs (some providers require it on refresh) + if ( + hasattr(client, "client_kwargs") + and client.client_kwargs.get("scope") + and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE + ): + refresh_data["scope"] = client.client_kwargs["scope"] + # Make refresh request async with aiohttp.ClientSession(trust_env=True) as session_http: async with session_http.post( @@ -1684,7 +1706,9 @@ class OAuthManager: redirect_url = f"{redirect_base_url}/auth" if error_message: - redirect_url = f"{redirect_url}?error={error_message}" + redirect_url = ( + f"{redirect_url}?error={urllib.parse.quote_plus(error_message)}" + ) return RedirectResponse(url=redirect_url, headers=response.headers) response = RedirectResponse(url=redirect_url, headers=response.headers) diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index abc8920884..0ea525c93e 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -142,41 +142,121 @@ def replace_prompt_variable(template: str, prompt: str) -> str: return template +def truncate_content(content: str, max_chars: int, mode: str = "middletruncate") -> str: + """Truncate a string to max_chars using the specified mode. + + Modes: + - middletruncate: keep beginning and end, join with '...' + - start: keep first max_chars characters + - end: keep last max_chars characters + """ + if not content or len(content) <= max_chars: + return content + + if mode == "start": + return content[:max_chars] + elif mode == "end": + return content[-max_chars:] + else: # middletruncate + half = max_chars // 2 + return f"{content[:half]}...{content[-(max_chars - half):]}" + + +def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]: + """Apply a content filter to each message's content. + + filter_str is like 'middletruncate:500', 'start:200', or 'end:200'. + Returns a new list with truncated content (original messages are not mutated). + """ + parts = filter_str.split(":") + if len(parts) != 2: + return messages + + mode = parts[0].lower() + try: + max_chars = int(parts[1]) + except ValueError: + return messages + + if mode not in ("middletruncate", "start", "end"): + return messages + + result = [] + for msg in messages: + new_msg = dict(msg) + if isinstance(new_msg.get("content"), str): + new_msg["content"] = truncate_content(new_msg["content"], max_chars, mode) + elif isinstance(new_msg.get("content"), list): + new_content = [] + for item in new_msg["content"]: + if isinstance(item, dict) and item.get("type") == "text": + new_item = dict(item) + new_item["text"] = truncate_content( + item.get("text", ""), max_chars, mode + ) + new_content.append(new_item) + else: + new_content.append(item) + new_msg["content"] = new_content + result.append(new_msg) + return result + + def replace_messages_variable( template: str, messages: Optional[list[dict]] = None ) -> str: def replacement_function(match): - full_match = match.group(0) - start_length = match.group(1) - end_length = match.group(2) - middle_length = match.group(3) + # Groups: (1) filter for bare MESSAGES + # (2) START count, (3) filter for START + # (4) END count, (5) filter for END + # (6) MIDDLE count,(7) filter for MIDDLE + bare_filter = match.group(1) + start_length = match.group(2) + start_filter = match.group(3) + end_length = match.group(4) + end_filter = match.group(5) + middle_length = match.group(6) + middle_filter = match.group(7) + # If messages is None, handle it as an empty list if messages is None: return "" - # Process messages based on the number of messages required - if full_match == "{{MESSAGES}}": - return get_messages_content(messages) - elif start_length is not None: - return get_messages_content(messages[: int(start_length)]) + # Select messages based on the variant + if start_length is not None: + selected = messages[: int(start_length)] + content_filter = start_filter elif end_length is not None: - return get_messages_content(messages[-int(end_length) :]) + selected = messages[-int(end_length) :] + content_filter = end_filter elif middle_length is not None: mid = int(middle_length) - if len(messages) <= mid: - return get_messages_content(messages) - # Handle middle truncation: split to get start and end portions of the messages list - half = mid // 2 - start_msgs = messages[:half] - end_msgs = messages[-half:] if mid % 2 == 0 else messages[-(half + 1) :] - formatted_start = get_messages_content(start_msgs) - formatted_end = get_messages_content(end_msgs) - return f"{formatted_start}\n{formatted_end}" - return "" + selected = messages + else: + half = mid // 2 + start_msgs = messages[:half] + end_msgs = messages[-half:] if mid % 2 == 0 else messages[-(half + 1) :] + selected = start_msgs + end_msgs + content_filter = middle_filter + else: + # Bare {{MESSAGES}} or {{MESSAGES|filter}} + selected = messages + content_filter = bare_filter + + # Apply content filter if present + if content_filter: + selected = apply_content_filter(selected, content_filter) + + return get_messages_content(selected) template = re.sub( - r"{{MESSAGES}}|{{MESSAGES:START:(\d+)}}|{{MESSAGES:END:(\d+)}}|{{MESSAGES:MIDDLETRUNCATE:(\d+)}}", + r"(?:" + r"\{\{MESSAGES(?:\|(\w+:\d+))?\}\}" + r"|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}" + r"|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}" + r"|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}" + r")", replacement_function, template, ) diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 17536b9adb..25cd027d0e 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -20,6 +20,7 @@ from opentelemetry.instrumentation.redis import RedisInstrumentor from opentelemetry.instrumentation.requests import RequestsInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor from opentelemetry.trace import Span, StatusCode from redis import Redis from redis.cluster import RedisCluster @@ -204,6 +205,7 @@ class Instrumentor(BaseInstrumentor): request_hook=aiohttp_request_hook, response_hook=aiohttp_response_hook, ) + SystemMetricsInstrumentor().instrument() def _uninstrument(self, **kwargs): if getattr(self, "instrumentors", None) is None: diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index f129f5f002..cafe779d80 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -120,12 +120,12 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: # Instruments request_counter = meter.create_counter( name="http.server.requests", - description="Total HTTP requests", + description="Counts the total number of inbound HTTP requests.", unit="1", ) duration_histogram = meter.create_histogram( name="http.server.duration", - description="HTTP request duration", + description="Measures the duration of inbound HTTP requests.", unit="ms", ) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index ab9c1b661b..e525a8284c 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -432,6 +432,10 @@ def get_builtin_tools( # If model has attached knowledge (any type), only provide query_knowledge_files # Otherwise, provide all KB browsing tools model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", []) + # Merge folder-attached knowledge so builtin tools can search it + folder_knowledge = extra_params.get("__metadata__", {}).get("folder_knowledge") + if folder_knowledge: + model_knowledge = list(model_knowledge or []) + list(folder_knowledge) if is_builtin_tool_enabled("knowledge"): if model_knowledge: # Model has attached knowledge - only allow semantic search within it @@ -703,8 +707,8 @@ def get_functions_from_tool(tool: object) -> list[Callable]: getattr(tool, func) ) # checks if the attribute is callable (a method or function). and not func.startswith( - "__" - ) # filters out special (dunder) methods like init, str, etc. — these are usually built-in functions of an object that you might not need to use directly. + "_" + ) # filters out internal methods (starting with _) and special (dunder) methods. and not inspect.isclass( getattr(tool, func) ) # ensures that the callable is not a class itself, just a method or function. @@ -1186,7 +1190,7 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, { "id": str(id), "idx": idx, - "url": server.get("url"), + "url": (server.get("url") or "").rstrip("/"), "openapi": openapi_data, "info": response.get("info"), "specs": response.get("specs"), @@ -1247,9 +1251,10 @@ async def execute_tool_server( if param_in == "path": path_params[param_name] = params[param_name] elif param_in == "query": - query_params[param_name] = params[param_name] + if params[param_name] is not None: + query_params[param_name] = params[param_name] - final_url = f"{url}{route_path}" + final_url = f"{url.rstrip('/')}{route_path}" for key, value in path_params.items(): final_url = final_url.replace(f"{{{key}}}", str(value)) @@ -1319,6 +1324,8 @@ def get_tool_server_url(url: Optional[str], path: str) -> str: if "://" in path: # If it contains "://", it's a full URL return path + if url: + url = url.rstrip("/") if not path.startswith("/"): # Ensure the path starts with a slash path = f"/{path}" diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index eb2688851f..b3a3c6bcd1 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -26,9 +26,13 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b # Microsoft Teams Webhooks elif "webhook.office.com" in url: action = event_data.get("action", "undefined") + user_data = event_data.get("user", "{}") + if isinstance(user_data, dict): + user_dict = user_data + else: + user_dict = json.loads(user_data) facts = [ - {"name": name, "value": value} - for name, value in json.loads(event_data.get("user", {})).items() + {"name": name, "value": value} for name, value in user_dict.items() ] payload = { "@type": "MessageCard", diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 532a6cd714..13bd199f08 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -1,8 +1,8 @@ # Minimal requirements for backend to run # WIP: use this as a reference to build a minimal docker image -fastapi==0.128.5 -uvicorn[standard]==0.40.0 +fastapi==0.135.1 +uvicorn[standard]==0.41.0 pydantic==2.12.5 python-multipart==0.0.22 itsdangerous==2.2.0 @@ -13,7 +13,7 @@ cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.7 +authlib==1.6.9 requests==2.32.5 aiohttp==3.13.2 # do not update to 3.13.3 - broken @@ -25,12 +25,12 @@ Brotli==1.1.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 -sqlalchemy==2.0.46 -alembic==1.18.3 +sqlalchemy==2.0.48 +alembic==1.18.4 peewee==3.19.0 peewee-migrate==1.14.3 -pycrdt==0.12.46 +pycrdt==0.12.47 redis APScheduler==3.11.2 @@ -42,14 +42,15 @@ asgiref==3.11.1 mcp==1.26.0 openai -langchain==1.2.9 +langchain==1.2.10 langchain-community==0.4.1 langchain-classic==1.0.1 -langchain-text-splitters==1.1.0 +langchain-text-splitters==1.1.1 fake-useragent==2.2.0 -chromadb==1.4.1 +chromadb==1.5.2 black==26.1.0 pydub chardet==5.2.0 +beautifulsoup4 diff --git a/backend/requirements.txt b/backend/requirements.txt index 5c1f044d1d..295b6cfa89 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ -fastapi==0.128.5 -uvicorn[standard]==0.40.0 +fastapi==0.135.1 +uvicorn[standard]==0.41.0 pydantic==2.12.5 python-multipart==0.0.22 itsdangerous==2.2.0 @@ -10,7 +10,7 @@ cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.7 +authlib==1.6.9 requests==2.32.5 aiohttp==3.13.2 # do not update to 3.13.3 - broken @@ -23,17 +23,17 @@ httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 -sqlalchemy==2.0.46 -alembic==1.18.3 +sqlalchemy==2.0.48 +alembic==1.18.4 peewee==3.19.0 peewee-migrate==1.14.3 -pycrdt==0.12.46 +pycrdt==0.12.47 redis APScheduler==3.11.2 RestrictedPython==8.1 -pytz==2025.2 +pytz==2026.1.post1 loguru==0.7.3 asgiref==3.11.1 @@ -44,37 +44,38 @@ mcp==1.26.0 openai anthropic -google-genai==1.62.0 +google-genai==1.66.0 -langchain==1.2.9 +langchain==1.2.10 langchain-community==0.4.1 langchain-classic==1.0.1 -langchain-text-splitters==1.1.0 +langchain-text-splitters==1.1.1 fake-useragent==2.2.0 -chromadb==1.4.1 -weaviate-client==4.19.2 +chromadb==1.5.2 +weaviate-client==4.20.3 opensearch-py==3.1.0 -transformers==5.1.0 -sentence-transformers==5.2.2 +transformers==5.3.0 +sentence-transformers==5.2.3 accelerate pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.2 ftfy==6.3.1 chardet==5.2.0 -pypdf==6.7.0 -fpdf2==2.8.5 -pymdown-extensions==10.20.1 +pypdf==6.7.5 +fpdf2==2.8.7 +pymdown-extensions==10.21 docx2txt==0.9 python-pptx==1.0.2 unstructured==0.18.31 msoffcrypto-tool==6.0.0 -nltk==3.9.2 -Markdown==3.10.1 +nltk==3.9.3 +Markdown==3.10.2 +beautifulsoup4 pypandoc==1.16.2 -pandas==3.0.0 +pandas==3.0.1 openpyxl==3.1.5 pyxlsb==1.0.10 xlrd==2.0.2 @@ -83,12 +84,12 @@ psutil sentencepiece soundfile==0.13.1 -pillow==12.1.0 +pillow==12.1.1 opencv-python-headless==4.13.0.92 rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 -onnxruntime==1.24.1 +onnxruntime==1.24.3 faster-whisper==1.2.1 black==26.1.0 @@ -96,10 +97,10 @@ youtube-transcript-api==1.2.4 pytube==15.0.0 pydub -ddgs==9.10.0 +ddgs==9.11.2 azure-ai-documentintelligence==1.0.2 -azure-identity==1.25.1 +azure-identity==1.25.2 azure-storage-blob==12.28.0 azure-search-documents==11.6.0 @@ -117,10 +118,11 @@ psycopg2-binary==2.9.11 pgvector==0.4.2 PyMySQL==1.1.2 -boto3==1.42.44 +boto3==1.42.62 +mariadb==1.1.14 -pymilvus==2.6.8 -qdrant-client==1.16.2 +pymilvus==2.6.9 +qdrant-client==1.17.0 playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary elasticsearch==9.3.0 pinecone==6.0.2 @@ -140,17 +142,18 @@ pytest-docker~=3.2.5 ldap3==2.9.1 ## Firecrawl -firecrawl-py==4.14.0 +firecrawl-py==4.18.0 ## Trace -opentelemetry-api==1.39.1 -opentelemetry-sdk==1.39.1 -opentelemetry-exporter-otlp==1.39.1 -opentelemetry-instrumentation==0.60b1 -opentelemetry-instrumentation-fastapi==0.60b1 -opentelemetry-instrumentation-sqlalchemy==0.60b1 -opentelemetry-instrumentation-redis==0.60b1 -opentelemetry-instrumentation-requests==0.60b1 -opentelemetry-instrumentation-logging==0.60b1 -opentelemetry-instrumentation-httpx==0.60b1 -opentelemetry-instrumentation-aiohttp-client==0.60b1 +opentelemetry-api==1.40.0 +opentelemetry-sdk==1.40.0 +opentelemetry-exporter-otlp==1.40.0 +opentelemetry-instrumentation==0.61b0 +opentelemetry-instrumentation-fastapi==0.61b0 +opentelemetry-instrumentation-sqlalchemy==0.61b0 +opentelemetry-instrumentation-redis==0.61b0 +opentelemetry-instrumentation-requests==0.61b0 +opentelemetry-instrumentation-logging==0.61b0 +opentelemetry-instrumentation-httpx==0.61b0 +opentelemetry-instrumentation-aiohttp-client==0.61b0 +opentelemetry-instrumentation-system-metrics==0.61b0 diff --git a/package-lock.json b/package-lock.json index 7681c6b6f9..04f3973311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.6", + "version": "0.8.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.6", + "version": "0.8.10", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -22,6 +22,7 @@ "@sveltejs/svelte-virtual-list": "^3.0.1", "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", + "@tiptap/extension-code": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", @@ -38,6 +39,8 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", @@ -64,6 +67,7 @@ "idb": "^7.1.1", "js-sha256": "^0.10.1", "jspdf": "^4.0.0", + "jszip": "^3.10.1", "katex": "^0.16.22", "kokoro-js": "^1.1.1", "leaflet": "^1.9.4", @@ -87,8 +91,10 @@ "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", "pyodide": "^0.28.2", + "shiki": "^4.0.1", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", + "sql.js": "^1.14.1", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", @@ -181,22 +187,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, "node_modules/@azure/msal-browser": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", @@ -656,131 +646,6 @@ "node": ">=0.1.90" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", - "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@cypress/request": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", @@ -2817,6 +2682,106 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.1.tgz", + "integrity": "sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.1", + "@shikijs/types": "4.0.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.1.tgz", + "integrity": "sha512-DJK9NiwtGYqMuKCRO4Ip0FKNDQpmaiS+K5bFjJ7DWFn4zHueDWgaUG8kAofkrnXF6zPPYYQY7J5FYVW9MbZyBg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.1", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.1.tgz", + "integrity": "sha512-oCWdCTDch3J8Kc0OZJ98KuUPC02O1VqIE3W/e2uvrHqTxYRR21RGEJMtchrgrxhsoJJCzmIciKsqG+q/yD+Cxg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.1", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.1.tgz", + "integrity": "sha512-v/mluaybWdnGJR4GqAR6zh8qAZohW9k+cGYT28Y7M8+jLbC0l4yG085O1A+WkseHTn+awd+P3UBymb2+MXFc8w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.1.tgz", + "integrity": "sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.1.tgz", + "integrity": "sha512-FW41C/D6j/yKQkzVdjrRPiJCtgeDaYRJFEyCKFCINuRJRj9WcmubhP4KQHPZ4+9eT87jruSrYPyoblNRyDFzvA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.1.tgz", + "integrity": "sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3405,9 +3370,9 @@ } }, "node_modules/@tiptap/extension-collaboration": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz", - "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.1.tgz", + "integrity": "sha512-JnwLvyzrutBffHp6YPnf0XyTnhAgqZ9D8JSUKFp0edvai+dxsb+UMlawesBrgAuoQXw3B8YZUo2VFDVdKas1xQ==", "license": "MIT", "peer": true, "funding": { @@ -3415,9 +3380,9 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.5", - "@tiptap/pm": "^3.4.5", - "@tiptap/y-tiptap": "^3.0.0-beta.3", + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1", + "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, @@ -3664,9 +3629,9 @@ } }, "node_modules/@tiptap/extension-node-range": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz", - "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.1.tgz", + "integrity": "sha512-+W/mQJxlkXMcwldWUqwdoR0eniJ1S9cVJoAy2Lkis0NhILZDWVNGKl9J4WFoCOXn8Myr17IllIxRYvAXJJ4FHQ==", "license": "MIT", "peer": true, "funding": { @@ -3674,8 +3639,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.5", - "@tiptap/pm": "^3.4.5" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-ordered-list": { @@ -3745,9 +3710,9 @@ } }, "node_modules/@tiptap/extension-text-style": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.0.7.tgz", - "integrity": "sha512-naJ1XxlbFJ1qlpA+i54lQYKuhWP1dnkUslM86OT0TZt0zJBeu7LIrqSOVGmMB++lF/btnQLMnYkYSSnkLgIw3A==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.1.tgz", + "integrity": "sha512-3LQU92zX6tzl47EBskkAKeJXd6EWwYmBDE7jbd7InJqnt9NMAcj4DtXtXpI+e6Un5+8yzNjVA+fI5+5cFS3dSg==", "license": "MIT", "peer": true, "funding": { @@ -3755,7 +3720,7 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-typography": { @@ -3892,9 +3857,9 @@ } }, "node_modules/@tiptap/y-tiptap": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz", - "integrity": "sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz", + "integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==", "license": "MIT", "peer": true, "dependencies": { @@ -4198,6 +4163,15 @@ "@types/mdurl": "^2" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", @@ -4261,9 +4235,10 @@ "optional": true }, "node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, "node_modules/@types/yauzl": { "version": "2.10.3", @@ -4620,6 +4595,18 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -4695,18 +4682,6 @@ "node": ">=0.8" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -5330,6 +5305,16 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -5378,6 +5363,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", @@ -5900,6 +5905,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -6056,31 +6071,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cypress": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", @@ -6736,22 +6726,6 @@ "node": ">=0.10" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -6774,15 +6748,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -8257,6 +8222,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/heic2any": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", @@ -8312,21 +8313,6 @@ "node": ">=12.0.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-entities": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", @@ -8348,6 +8334,16 @@ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -8395,22 +8391,6 @@ "entities": "^4.5.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -8425,22 +8405,6 @@ "node": ">=0.10" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -8801,15 +8765,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -8912,73 +8867,6 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, - "node_modules/jsdom": { - "version": "24.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", - "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9901,6 +9789,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -9973,6 +9882,95 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -10244,15 +10242,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10297,6 +10286,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, "node_modules/onnxruntime-common": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.20.1.tgz", @@ -10942,6 +10948,16 @@ "node": "10.* || >= 12.*" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/prosemirror-changeset": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", @@ -11394,6 +11410,30 @@ "node": ">=8.10.0" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -11620,15 +11660,6 @@ "points-on-path": "^0.2.1" } }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -12077,21 +12108,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -12188,6 +12204,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.1.tgz", + "integrity": "sha512-EkAEhDTN5WhpoQFXFw79OHIrSAfHhlImeCdSyg4u4XvrpxKEmdo/9x/HWSowujAnUrFsGOwWiE58a6GVentMnQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.1", + "@shikijs/engine-javascript": "4.0.1", + "@shikijs/engine-oniguruma": "4.0.1", + "@shikijs/langs": "4.0.1", + "@shikijs/themes": "4.0.1", + "@shikijs/types": "4.0.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -12314,12 +12349,28 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -12474,6 +12525,20 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -12746,15 +12811,6 @@ "node": ">=12.0.0" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -13016,19 +13072,14 @@ "node": ">= 4.0.0" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/ts-api-utils": { @@ -13187,6 +13238,74 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13696,6 +13815,34 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vinyl": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", @@ -14510,21 +14657,6 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -14563,18 +14695,6 @@ "node": "*" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -14598,22 +14718,6 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/wheel": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", @@ -14800,18 +14904,6 @@ "node": ">=0.8" } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -14821,15 +14913,6 @@ "node": ">=4.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", @@ -15041,6 +15124,16 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 8477f654b4..1eb043de5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.6", + "version": "0.8.10", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -66,6 +66,7 @@ "@sveltejs/svelte-virtual-list": "^3.0.1", "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", + "@tiptap/extension-code": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", @@ -82,6 +83,8 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", @@ -108,6 +111,7 @@ "idb": "^7.1.1", "js-sha256": "^0.10.1", "jspdf": "^4.0.0", + "jszip": "^3.10.1", "katex": "^0.16.22", "kokoro-js": "^1.1.1", "leaflet": "^1.9.4", @@ -131,8 +135,10 @@ "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", "pyodide": "^0.28.2", + "shiki": "^4.0.1", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", + "sql.js": "^1.14.1", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", diff --git a/pyproject.toml b/pyproject.toml index d240449dff..2ec9d033b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,8 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.128.5", - "uvicorn[standard]==0.40.0", + "fastapi==0.135.1", + "uvicorn[standard]==0.41.0", "pydantic==2.12.5", "python-multipart==0.0.22", "itsdangerous==2.2.0", @@ -18,7 +18,7 @@ dependencies = [ "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.11.0", - "authlib==1.6.7", + "authlib==1.6.9", "requests==2.32.5", "aiohttp==3.13.2", # do not update to 3.13.3 - broken @@ -31,15 +31,15 @@ dependencies = [ "starsessions[redis]==2.2.1", "python-mimeparse==2.0.0", - "sqlalchemy==2.0.46", - "alembic==1.18.3", + "sqlalchemy==2.0.48", + "alembic==1.18.4", "peewee==3.19.0", "peewee-migrate==1.14.3", - "pycrdt==0.12.46", + "pycrdt==0.12.47", "redis", - "pytz==2025.2", + "pytz==2026.1.post1", "APScheduler==3.11.2", "RestrictedPython==8.1", @@ -51,38 +51,38 @@ dependencies = [ "openai", "anthropic", - "google-genai==1.62.0", + "google-genai==1.66.0", - "langchain==1.2.9", + "langchain==1.2.10", "langchain-community==0.4.1", "langchain-classic==1.0.1", - "langchain-text-splitters==1.1.0", + "langchain-text-splitters==1.1.1", "fake-useragent==2.2.0", - "chromadb==1.4.1", + "chromadb==1.5.2", "opensearch-py==3.1.0", "PyMySQL==1.1.2", - "boto3==1.42.44", + "boto3==1.42.62", - "transformers==5.1.0", - "sentence-transformers==5.2.2", + "transformers==5.3.0", + "sentence-transformers==5.2.3", "accelerate", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.2", "ftfy==6.3.1", "chardet==5.2.0", - "pypdf==6.7.0", - "fpdf2==2.8.5", - "pymdown-extensions==10.20.1", + "pypdf==6.7.5", + "fpdf2==2.8.7", + "pymdown-extensions==10.21", "docx2txt==0.9", "python-pptx==1.0.2", "unstructured==0.18.31", "msoffcrypto-tool==6.0.0", - "nltk==3.9.2", - "Markdown==3.10.1", + "nltk==3.9.3", + "Markdown==3.10.2", "pypandoc==1.16.2", - "pandas==3.0.0", + "pandas==3.0.1", "openpyxl==3.1.5", "pyxlsb==1.0.10", "xlrd==2.0.2", @@ -92,12 +92,12 @@ dependencies = [ "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==12.1.0", + "pillow==12.1.1", "opencv-python-headless==4.13.0.92", "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", - "onnxruntime==1.24.1", + "onnxruntime==1.24.3", "faster-whisper==1.2.1", "black==26.1.0", @@ -105,7 +105,7 @@ dependencies = [ "pytube==15.0.0", "pydub", - "ddgs==9.10.0", + "ddgs==9.11.2", "google-api-python-client", "google-auth-httplib2", @@ -114,7 +114,7 @@ dependencies = [ "googleapis-common-protos==1.72.0", "google-cloud-storage==3.9.0", - "azure-identity==1.25.1", + "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "ldap3==2.9.1", @@ -137,11 +137,15 @@ postgres = [ "psycopg2-binary==2.9.11", "pgvector==0.4.2", ] +mariadb = [ + "mariadb==1.1.14", +] all = [ "pymongo", "psycopg2-binary==2.9.11", "pgvector==0.4.2", + "mariadb==1.1.14", "moto[s3]>=5.0.26", "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", @@ -150,15 +154,15 @@ all = [ "playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary "elasticsearch==9.3.0", - "qdrant-client==1.16.2", + "qdrant-client==1.17.0", - "weaviate-client==4.19.2", - "pymilvus==2.6.8", + "weaviate-client==4.20.3", + "pymilvus==2.6.9", "pinecone==6.0.2", "oracledb==3.4.2", "colbert-ai==0.2.22", - "firecrawl-py==4.14.0", + "firecrawl-py==4.18.0", "azure-search-documents==11.6.0", ] diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index 535adbd5f6..af5405de3c 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -4,6 +4,7 @@ type FolderForm = { name?: string; data?: Record; meta?: Record; + parent_id?: string | null; }; export const createNewFolder = async (token: string, folderForm: FolderForm) => { diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 5815bcd3c8..748ada33a0 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -5,6 +5,16 @@ export type FileEntry = { modified?: number; }; +export type ListeningPort = { + port: number; + pid: number | null; + process: string | null; +}; + +export type TerminalFeatures = { + terminal?: boolean; +}; + import { WEBUI_API_BASE_URL } from '$lib/constants'; export type TerminalServer = { @@ -23,6 +33,18 @@ export const getTerminalServers = async (token: string): Promise []); }; +export const getTerminalConfig = async ( + baseUrl: string, + apiKey: string +): Promise<{ features: TerminalFeatures } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/api/config`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + if (!res || !res.ok) return null; + return res.json().catch(() => null); +}; + export const getCwd = async (baseUrl: string, apiKey: string): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; const res = await fetch(url, { @@ -193,3 +215,124 @@ export const setCwd = async ( }); return res; }; + +export const moveEntry = async ( + baseUrl: string, + apiKey: string, + source: string, + destination: string +): Promise<{ source: string; destination: string } | { error: string }> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/move`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ source, destination }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal moveEntry error:', err); + return { error: err?.detail ?? 'Move failed' }; + }); + return res; +}; + +export const getListeningPorts = async ( + baseUrl: string, + apiKey: string +): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/ports`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + if (!res || !res.ok) return []; + const json = await res.json().catch(() => null); + return json?.ports ?? []; +}; + +export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''): string => { + return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`; +}; + +// --------------------------------------------------------------------------- +// Notebook execution +// --------------------------------------------------------------------------- + +export const createNotebookSession = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise<{ id: string; kernel: string; status: string } | { error: string }> => { + const url = `${baseUrl.replace(/\/$/, '')}/notebooks`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path }) + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + return { error: body?.detail ?? `HTTP ${res.status}` }; + } + return res.json(); + }) + .catch((err) => { + console.error('open-terminal createNotebookSession error:', err); + return { error: 'Connection failed' }; + }); + return res; +}; + +export const executeNotebookCell = async ( + baseUrl: string, + apiKey: string, + sessionId: string, + cellIndex: number, + source?: string +): Promise<{ status: string; execution_count?: number; outputs: any[] } | { error: string }> => { + const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}/execute`; + const body: Record = { cell_index: cellIndex }; + if (source !== undefined) body.source = source; + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + return { error: body?.detail ?? `HTTP ${res.status}` }; + } + return res.json(); + }) + .catch((err) => { + console.error('open-terminal executeNotebookCell error:', err); + return { error: 'Connection failed' }; + }); + return res; +}; + +export const stopNotebookSession = async ( + baseUrl: string, + apiKey: string, + sessionId: string +): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + return res?.ok ?? false; +}; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 8a8e269aea..76d67216f0 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -699,7 +699,7 @@ {/if} diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte index 233eada3dc..c26460549e 100644 --- a/src/lib/components/AddTerminalServerModal.svelte +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -25,7 +25,7 @@ let id = ''; let auth_type = 'bearer'; let path = '/openapi.json'; - let enabled = true; + let enabled = false; let showAdvanced = false; let showAccessControlModal = false; let accessGrants: any[] = []; @@ -47,7 +47,7 @@ name = ''; auth_type = 'bearer'; path = '/openapi.json'; - enabled = true; + enabled = false; accessGrants = []; } }; diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index db61a58e77..2f5e30a7c5 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -898,7 +898,7 @@ {/if} diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte index fff9c12acf..2163ce26c8 100644 --- a/src/lib/components/ImportModal.svelte +++ b/src/lib/components/ImportModal.svelte @@ -104,7 +104,7 @@
+ {/if} diff --git a/src/lib/components/admin/Settings/CodeExecution.svelte b/src/lib/components/admin/Settings/CodeExecution.svelte index 23388c0a45..6195672869 100644 --- a/src/lib/components/admin/Settings/CodeExecution.svelte +++ b/src/lib/components/admin/Settings/CodeExecution.svelte @@ -67,7 +67,8 @@ > {#each engines as engine} - + {/each} @@ -193,7 +194,9 @@ > {#each engines as engine} - + {/each} diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 946d4a32cd..f8d6193b7d 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -221,6 +221,12 @@ const res = await updateRAGConfig(localStorage.token, { ...RAGConfig, + // Convert null (from cleared number inputs) to empty string so the backend + // can distinguish "clear this field" from "don't change this field" + FILE_MAX_SIZE: RAGConfig.FILE_MAX_SIZE ?? '', + FILE_MAX_COUNT: RAGConfig.FILE_MAX_COUNT ?? '', + FILE_IMAGE_COMPRESSION_WIDTH: RAGConfig.FILE_IMAGE_COMPRESSION_WIDTH ?? '', + FILE_IMAGE_COMPRESSION_HEIGHT: RAGConfig.FILE_IMAGE_COMPRESSION_HEIGHT ?? '', ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',') .map((ext) => ext.trim()) .filter((ext) => ext !== ''), diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index bf3ce6da19..5fe84d700a 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -1270,7 +1270,7 @@
+ {/if} diff --git a/src/lib/components/admin/Settings/Interface/Banners.svelte b/src/lib/components/admin/Settings/Interface/Banners.svelte index 6b96374d5e..5ebf9c7685 100644 --- a/src/lib/components/admin/Settings/Interface/Banners.svelte +++ b/src/lib/components/admin/Settings/Interface/Banners.svelte @@ -64,9 +64,7 @@ bind:value={banner.type} required > - {#if banner.type == ''} - - {/if} + diff --git a/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte b/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte index 1850940503..14b6e6d34c 100644 --- a/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte +++ b/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte @@ -436,7 +436,7 @@ diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index c93fc37ebd..b3290a11e4 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -219,7 +219,7 @@ {#if ['general', 'permissions'].includes(selectedTab)}
+ {/if} diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ac1daade87..ace5141a7f 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -31,7 +31,7 @@ let query = ''; let searchDebounceTimer: ReturnType; - let orderBy = 'created_at'; // default sort key + let orderBy = groupId ? `group_id:${groupId}` : 'last_active_at'; // default sort key let direction = 'desc'; // default sort order let page = 1; diff --git a/src/lib/components/admin/Users/UserList/AddUserModal.svelte b/src/lib/components/admin/Users/UserList/AddUserModal.svelte index 191a8b134e..84cfd4ffb6 100644 --- a/src/lib/components/admin/Users/UserList/AddUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/AddUserModal.svelte @@ -285,7 +285,7 @@
+ {/if} diff --git a/src/lib/components/channel/WebhooksModal.svelte b/src/lib/components/channel/WebhooksModal.svelte index c095e38018..820a7f3cb5 100644 --- a/src/lib/components/channel/WebhooksModal.svelte +++ b/src/lib/components/channel/WebhooksModal.svelte @@ -160,7 +160,7 @@
diff --git a/src/lib/components/chat/Artifacts.svelte b/src/lib/components/chat/Artifacts.svelte index bf0681dc19..7e0bdf2060 100644 --- a/src/lib/components/chat/Artifacts.svelte +++ b/src/lib/components/chat/Artifacts.svelte @@ -90,24 +90,32 @@ }; onMount(() => { - artifactCode.subscribe((value) => { + const unsubscribeArtifactCode = artifactCode.subscribe((value) => { if (contents) { const codeIdx = contents.findIndex((content) => content.content.includes(value)); selectedContentIdx = codeIdx !== -1 ? codeIdx : 0; } }); - artifactContents.subscribe((value) => { - contents = value; - console.log('Artifact contents updated:', contents); + const unsubscribeArtifactContents = artifactContents.subscribe((value) => { + const newContents = value ?? []; + console.log('Artifact contents updated:', newContents); - if (contents.length === 0) { + if (newContents.length === 0) { showControls.set(false); showArtifacts.set(false); + selectedContentIdx = 0; + } else if (newContents.length > contents.length) { + selectedContentIdx = newContents.length - 1; } - selectedContentIdx = contents ? contents.length - 1 : 0; + contents = newContents; }); + + return () => { + unsubscribeArtifactCode(); + unsubscribeArtifactContents(); + }; }); diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 14a7de457f..8ea8641f39 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -65,6 +65,7 @@ import { AudioQueue } from '$lib/utils/audio'; import { + archiveChatById, createNewChat, getAllTags, getChatById, @@ -104,6 +105,7 @@ import Tooltip from '../common/Tooltip.svelte'; import Sidebar from '../icons/Sidebar.svelte'; import Image from '../common/Image.svelte'; + import { getBanners } from '$lib/apis/configs'; export let chatIdProp = ''; @@ -146,34 +148,6 @@ let webSearchEnabled = false; let codeInterpreterEnabled = false; - // Auto-inject direct terminal servers into selected tool IDs so they act like toggled-on tools - // System terminals (with id field) are handled server-side via terminal_id, not as direct tool servers - $: if ($terminalServers && $terminalServers.length > 0) { - const directTerminalServers = $terminalServers.filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}` - ); - const missingIds = terminalIds.filter((id) => !selectedToolIds.includes(id)); - if (missingIds.length > 0) { - selectedToolIds = [...selectedToolIds, ...missingIds]; - } - } - - // Remove disabled terminal servers from selectedToolIds automatically - $: if (selectedToolIds.length > 0) { - const directTerminalServers = ($terminalServers ?? []).filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => - `direct_server:terminal_${($terminalServers ?? []).indexOf(directTerminalServers[i])}` - ); - const invalidTerminalIds = selectedToolIds.filter( - (id) => id.startsWith('direct_server:terminal_') && !terminalIds.includes(id) - ); - if (invalidTerminalIds.length > 0) { - selectedToolIds = selectedToolIds.filter((id) => !invalidTerminalIds.includes(id)); - } - } - let showCommands = false; let generating = false; @@ -354,25 +328,12 @@ [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id)) ) ]; - } else if ( - $settings?.tools && - $settings.tools.some((id) => !id.startsWith('direct_server:terminal_')) - ) { + } else if ($settings?.tools) { selectedToolIds = $settings.tools; } else { - // Don't wipe existing terminal servers if no default tool IDs selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:')); } - // Auto-inject direct terminal servers (system ones are handled via terminal_id) - if ($terminalServers && $terminalServers.length > 0) { - const directTerminalServers = $terminalServers.filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}` - ); - selectedToolIds = [...new Set([...selectedToolIds, ...terminalIds])]; - } - // Set Default Filters (Toggleable only) if (model?.info?.meta?.defaultFilterIds) { selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) => @@ -446,11 +407,14 @@ }; const terminalEventHandler = (type: string, data: any) => { - if (!data?.path) return; if (type === 'terminal:display_file') { + if (!data?.path) return; displayFileHandler(data.path, { showControls, showFileNavPath }); - } else if (type === 'terminal:write_file') { + } else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') { + if (!data?.path) return; showFileNavDir.set(data.path); + } else if (type === 'terminal:run_command') { + showFileNavDir.set('/'); } }; @@ -685,6 +649,13 @@ if (p.url.pathname === '/') { await tick(); initNewChat(); + + // Re-fetch banners on navigation to homepage so newly configured banners appear + try { + banners.set(await getBanners(localStorage.token).catch(() => [])); + } catch (e) { + console.error('Failed to refresh banners:', e); + } } stopAudio(); @@ -963,15 +934,19 @@ } }; - $: if (history) { - cancelAnimationFrame(contentsRAF); - contentsRAF = requestAnimationFrame(() => { - getContents(); - contentsRAF = null; - }); - } else { - artifactContents.set([]); - } + const onHistoryChange = (history) => { + if (history) { + cancelAnimationFrame(contentsRAF); + contentsRAF = requestAnimationFrame(() => { + getContents(); + contentsRAF = null; + }); + } else { + artifactContents.set([]); + } + }; + + $: onHistoryChange(history); const getContents = () => { const messages = history ? createMessagesList(history, history.currentId) : []; @@ -1602,27 +1577,29 @@ navigator.vibrate(5); } - // Emit chat event for TTS - const messageContentParts = getMessageContentParts( - removeAllDetails(message.content), - $config?.audio?.tts?.split_on ?? 'punctuation' - ); - messageContentParts.pop(); - - // dispatch only last sentence and make sure it hasn't been dispatched before - if ( - messageContentParts.length > 0 && - messageContentParts[messageContentParts.length - 1] !== message.lastSentence - ) { - message.lastSentence = messageContentParts[messageContentParts.length - 1]; - eventTarget.dispatchEvent( - new CustomEvent('chat', { - detail: { - id: message.id, - content: messageContentParts[messageContentParts.length - 1] - } - }) + // Emit chat event for TTS (only when call overlay is active) + if ($showCallOverlay) { + const messageContentParts = getMessageContentParts( + removeAllDetails(message.content), + $config?.audio?.tts?.split_on ?? 'punctuation' ); + messageContentParts.pop(); + + // dispatch only last sentence and make sure it hasn't been dispatched before + if ( + messageContentParts.length > 0 && + messageContentParts[messageContentParts.length - 1] !== message.lastSentence + ) { + message.lastSentence = messageContentParts[messageContentParts.length - 1]; + eventTarget.dispatchEvent( + new CustomEvent('chat', { + detail: { + id: message.id, + content: messageContentParts[messageContentParts.length - 1] + } + }) + ); + } } } } @@ -1636,27 +1613,29 @@ navigator.vibrate(5); } - // Emit chat event for TTS - const messageContentParts = getMessageContentParts( - removeAllDetails(message.content), - $config?.audio?.tts?.split_on ?? 'punctuation' - ); - messageContentParts.pop(); - - // dispatch only last sentence and make sure it hasn't been dispatched before - if ( - messageContentParts.length > 0 && - messageContentParts[messageContentParts.length - 1] !== message.lastSentence - ) { - message.lastSentence = messageContentParts[messageContentParts.length - 1]; - eventTarget.dispatchEvent( - new CustomEvent('chat', { - detail: { - id: message.id, - content: messageContentParts[messageContentParts.length - 1] - } - }) + // Emit chat event for TTS (only when call overlay is active) + if ($showCallOverlay) { + const messageContentParts = getMessageContentParts( + removeAllDetails(message.content), + $config?.audio?.tts?.split_on ?? 'punctuation' ); + messageContentParts.pop(); + + // dispatch only last sentence and make sure it hasn't been dispatched before + if ( + messageContentParts.length > 0 && + messageContentParts[messageContentParts.length - 1] !== message.lastSentence + ) { + message.lastSentence = messageContentParts[messageContentParts.length - 1]; + eventTarget.dispatchEvent( + new CustomEvent('chat', { + detail: { + id: message.id, + content: messageContentParts[messageContentParts.length - 1] + } + }) + ); + } } } @@ -1683,18 +1662,20 @@ document.getElementById(`speak-button-${message.id}`)?.click(); } - // Emit chat event for TTS - let lastMessageContentPart = - getMessageContentParts( - removeAllDetails(message.content), - $config?.audio?.tts?.split_on ?? 'punctuation' - )?.at(-1) ?? ''; - if (lastMessageContentPart) { - eventTarget.dispatchEvent( - new CustomEvent('chat', { - detail: { id: message.id, content: lastMessageContentPart } - }) - ); + // Emit chat event for TTS (only when call overlay is active) + if ($showCallOverlay) { + let lastMessageContentPart = + getMessageContentParts( + removeAllDetails(message.content), + $config?.audio?.tts?.split_on ?? 'punctuation' + )?.at(-1) ?? ''; + if (lastMessageContentPart) { + eventTarget.dispatchEvent( + new CustomEvent('chat', { + detail: { id: message.id, content: lastMessageContentPart } + }) + ); + } } eventTarget.dispatchEvent( new CustomEvent('chat:finish', { @@ -2032,6 +2013,17 @@ return features; }; + const getStopTokens = () => { + const stop = params?.stop ?? $settings?.params?.stop; + if (!stop) return undefined; + + const tokens = Array.isArray(stop) ? stop : stop.split(',').map((s) => s.trim()); + + return tokens + .filter(Boolean) + .map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`))); + }; + const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => { const responseMessage = _history.messages[responseMessageId]; const userMessage = _history.messages[responseMessage.parentId]; @@ -2193,12 +2185,7 @@ params: { ...$settings?.params, ...params, - stop: - (params?.stop ?? $settings?.params?.stop ?? undefined) - ? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map( - (str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"')) - ) - : undefined + stop: getStopTokens() }, files: (files?.length ?? 0) > 0 ? files : undefined, @@ -2210,7 +2197,9 @@ tool_servers: [ ...($toolServers ?? []).filter( (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) - ) + ), + // Direct terminal servers — always included when enabled (not routed through selectedToolIds) + ...($terminalServers ?? []).filter((t) => !t.id) ], features: getFeatures(), variables: { @@ -2624,6 +2613,25 @@ toast.error($i18n.t('Failed to move chat')); } }; + + const archiveChatHandler = async (id: string) => { + try { + await archiveChatById(localStorage.token, id); + currentChatPage.set(1); + initNewChat(); + await goto('/'); + getChatList(localStorage.token, $currentChatPage).then((chats) => { + chats.set(chats); + }); + getPinnedChatList(localStorage.token).then((pinnedChats) => { + pinnedChats.set(pinnedChats); + }); + toast.success($i18n.t('Chat archived.')); + } catch (error) { + console.error('Error archiving chat:', error); + toast.error($i18n.t('Failed to archive chat.')); + } + }; @@ -2706,7 +2714,7 @@ bind:selectedModels shareEnabled={!!history.currentId} {initNewChat} - archiveChatHandler={() => {}} + {archiveChatHandler} {moveChatHandler} onSaveTempChat={async () => { try { @@ -2724,6 +2732,7 @@ id: uuidv4(), title: title.length > 50 ? `${title.slice(0, 50)}...` : title, models: selectedModels, + params: params, history: history, messages: messages, timestamp: Date.now() @@ -2915,6 +2924,7 @@ {stopResponse} {showMessage} {eventTarget} + {codeInterpreterEnabled} /> diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index e5398f7f91..aea9fa83cf 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -1,4 +1,4 @@ - @@ -10,6 +10,7 @@ import { onDestroy, onMount, tick, getContext } from 'svelte'; import { + config, terminalServers, mobile, showControls, @@ -31,6 +32,7 @@ import Artifacts from './Artifacts.svelte'; import Embeds from './ChatControls/Embeds.svelte'; import FileNav from './FileNav.svelte'; + import PyodideFileNav from './PyodideFileNav.svelte'; import Overview from './Overview.svelte'; const i18n = getContext('i18n'); @@ -50,21 +52,28 @@ export let files; export let modelId; - export let pane; + export let codeInterpreterEnabled = false; + + export let pane: Pane | null = null; - let mediaQuery; let largeScreen = false; let dragged = false; let minSize = 0; let paneReady = false; // Tab state for Controls+Files panel - let activeTab: 'controls' | 'files' | 'overview' = savedTab; - $: savedTab = activeTab; + let activeTab = savedTab; + // svelte-ignore reactive_declaration_module_script_dependency + $: { + savedTab = activeTab; + } + $: hasMessages = history?.messages && Object.keys(history.messages).length > 0; $: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true); - $: showFilesTab = !!$selectedTerminalId; + $: showFilesTab = + !!$selectedTerminalId || + (codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter'); $: showOverviewTab = hasMessages; // Tab fallback: if active tab becomes hidden, switch to next available @@ -172,56 +181,67 @@ dragged = false; }; - onMount(async () => { - mediaQuery = window.matchMedia('(min-width: 1024px)'); + onMount(() => { + const mediaQuery = window.matchMedia('(min-width: 1024px)'); mediaQuery.addEventListener('change', handleMediaQuery); handleMediaQuery(mediaQuery); + let resizeObserver: ResizeObserver | null = null; + let isDestroyed = false; + // Wait for Svelte to render the Pane after largeScreen changed - await tick(); + const init = async () => { + await tick(); - const container = document.getElementById('chat-container'); - minSize = Math.floor((350 / container.clientWidth) * 100); + if (isDestroyed) return; - const resizeObserver = new ResizeObserver((entries) => { - for (let entry of entries) { - const width = entry.contentRect.width; - minSize = Math.floor((350 / width) * 100); - if ($showControls) { - if (pane && pane.isExpanded() && pane.getSize() < minSize) { - pane.resize(minSize); - } else { - let size = Math.floor( - (parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100 - ); - if (size < minSize) pane.resize(minSize); + // If controls were persisted as open, set the pane to the saved size + if ($showControls && pane) { + openPane(); + } + + setTimeout(() => { + paneReady = true; + }, 0); + + const container = document.getElementById('chat-container') as HTMLElement; + if (!container) return; + + minSize = Math.floor((350 / container.clientWidth) * 100); + resizeObserver = new ResizeObserver((entries) => { + for (let entry of entries) { + const width = entry.contentRect.width; + minSize = Math.floor((350 / width) * 100); + if ($showControls) { + if (pane && pane.isExpanded() && pane.getSize() < minSize) { + pane.resize(minSize); + } else { + let size = Math.floor( + (parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100 + ); + if (size < minSize && pane) pane.resize(minSize); + } } } - } - }); - resizeObserver.observe(container); + }); + resizeObserver.observe(container); + }; + init(); document.addEventListener('mousedown', onMouseDown); document.addEventListener('mouseup', onMouseUp); - setTimeout(() => { - paneReady = true; - }, 0); - - // If controls were persisted as open, set the pane to the saved size - if ($showControls && pane) { - openPane(); - } - }); - - onDestroy(() => { - paneReady = false; - if (!largeScreen) { - showControls.set(false); - } - mediaQuery.removeEventListener('change', handleMediaQuery); - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mouseup', onMouseUp); + return () => { + isDestroyed = true; + paneReady = false; + resizeObserver?.disconnect(); + if (!largeScreen) { + showControls.set(false); + } + mediaQuery.removeEventListener('change', handleMediaQuery); + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mouseup', onMouseUp); + }; }); const closeHandler = () => { @@ -270,10 +290,11 @@
-
+
{#if showControlsTab}
{:else}
(isDragOver = false)} on:drop={handleDrop} @@ -461,12 +632,20 @@ {selectedFile} {loading} onNavigate={loadDir} - onRefresh={() => loadDir(currentPath)} + onRefresh={() => { + if (selectedFile) { + const fileName = selectedFile.split('/').pop() ?? ''; + openEntry({ name: fileName, type: 'file', size: 0 }); + } else { + loadDir(currentPath); + } + }} onNewFolder={startNewFolder} onNewFile={startNewFile} onUploadFiles={handleUploadFiles} + onMove={handleMove} > - {#if fileImageUrl !== null} + {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} + + {:else if isHtml} + + {:else if isCode} + + + + {:else if editing} + + {/if} + + {#if terminalExpanded} +
+ +
+ {/if} +
+ {/if}
{/if} diff --git a/src/lib/components/chat/FileNav/CellEditor.svelte b/src/lib/components/chat/FileNav/CellEditor.svelte new file mode 100644 index 0000000000..51156bcebd --- /dev/null +++ b/src/lib/components/chat/FileNav/CellEditor.svelte @@ -0,0 +1,114 @@ + + +
+ + diff --git a/src/lib/components/chat/FileNav/FileCodeEditor.svelte b/src/lib/components/chat/FileNav/FileCodeEditor.svelte new file mode 100644 index 0000000000..d2c7f3a5b3 --- /dev/null +++ b/src/lib/components/chat/FileNav/FileCodeEditor.svelte @@ -0,0 +1,139 @@ + + +
+ + diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index c5d778555e..eb217bcc9b 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -12,30 +12,76 @@ export let entry: FileEntry; export let currentPath: string; - export let terminalUrl: string; - export let terminalKey: string; + export let terminalUrl: string = ''; + export let terminalKey: string = ''; export let onOpen: (entry: FileEntry) => void = () => {}; export let onDownload: (path: string) => void = () => {}; export let onDelete: (path: string, name: string) => void = () => {}; + export let onMove: (source: string, destFolder: string) => void = () => {}; + + let dragOverFolder = false;
  • -
    +
    { + if (entry.type !== 'directory') return; + if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return; + e.preventDefault(); + e.stopPropagation(); + dragOverFolder = true; + }} + on:dragleave={(e) => { + if (entry.type !== 'directory') return; + e.stopPropagation(); + dragOverFolder = false; + }} + on:drop={(e) => { + if (entry.type !== 'directory') return; + const raw = e.dataTransfer?.getData('application/x-terminal-file-move'); + if (!raw) return; + e.preventDefault(); + e.stopPropagation(); + dragOverFolder = false; + try { + const data = JSON.parse(raw); + if (data.path) { + const destFolder = `${currentPath}${entry.name}/`; + // Don't allow dropping a folder onto itself + if (data.path + '/' === destFolder || data.path === destFolder) return; + onMove(data.path, destFolder); + } + } catch {} + }} + > @@ -55,27 +81,28 @@ {/if}
    - {#if !selectedFile} - - - + + + + + + {#if !selectedFile} + {/each} +
    + {/if} +
  • + {:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0} +
    +
    + Slide {currentSlide + 1} +
    + {#if fileOfficeSlides.length > 1} +
    + + {currentSlide + 1} / {fileOfficeSlides.length} + +
    + {/if} +
    {:else if fileContent !== null} - {#if isMarkdown && !showRaw} -
    + {#if isHtml && !showRaw} + {#if overlay} +
    + {/if} +