diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index 40cebc5028..f5b353dbcc 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -26,7 +26,7 @@ jobs: node-version: 22 - uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.12 - name: Build env: APP_BUILD_CHANNEL: main diff --git a/CHANGELOG.md b/CHANGELOG.md index a721b69cfe..515ed145e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,139 @@ 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.12.4] - 2026-10-25 + +### Added + +- 🎯 **Load selected skills on demand.** Administrators can turn on "Load selected skills on demand" under Admin Settings > General or set "ENABLE_SKILL_LAZY_LOADING", so skills selected in the chat or attached to a model are only listed for the model to load when it needs them instead of sending their full instructions with every message, while skills mentioned in the message still load right away, and it is off by default and needs the built-in Skills tool. [Commit](https://github.com/open-webui/open-webui/commit/079fb19e75f8be4b44fada0c755cf383b21a625b) +- 📍 **Compaction shown in chats.** A "Context compacted" line now marks the message where older messages were summarized, opens to show the summary and also appears inside a reply when compaction ran between its tool calls, Chat Controls > Overview tags that message, and compaction progress and errors now show in the chat instead of as pop-up notices. [Commit](https://github.com/open-webui/open-webui/commit/f465a4be11c0c64969da70f1f5b770bd21088b8a), [Commit](https://github.com/open-webui/open-webui/commit/c8786f426ad263ea4113652ed576d3c15ee341d6), [Commit](https://github.com/open-webui/open-webui/commit/3311f999928964ca1f7870fefb407553197dc2d5), [Commit](https://github.com/open-webui/open-webui/commit/3855acf02d44b78e7969599155896865fd362123), [#31324](https://github.com/open-webui/open-webui/issues/31324) +- 🗜️ **Compaction in more chats.** Context compaction now also runs in temporary chats and in short chats that are already over the threshold, such as one question with a reply full of tool output, so the follow-up and any regenerate or edit of it no longer fail with a context length error. [#32794](https://github.com/open-webui/open-webui/pull/32794), [Commit](https://github.com/open-webui/open-webui/commit/56eca192ab7d98dd9d1a9811e4452dc32145848d), [#32781](https://github.com/open-webui/open-webui/issues/32781), [Commit](https://github.com/open-webui/open-webui/commit/d5cd7f4907f9a6832dc5b52520a1381ef50a663c), [#32600](https://github.com/open-webui/open-webui/issues/32600) +- 🕶️ **Hide controls for users.** Administrators can now hide single controls from users under Visible controls in Admin Settings > Interface or with "UI_VISIBILITY", such as model capabilities, default features and built-in tools in the model editor, Capture, Compare text, Attach Webpage, Attach Files, Attach Notes, Attach Knowledge and Reference Chats in the + menu, the /status, /model and /settings commands, the usage info, Fork, Merge Responses, Preview, Artifacts, Archive and Tags in chats, Set as default and Keep in Sidebar for models and settings such as the Usage tab and the speech engines, while hidden controls keep their values and the features behind them stay usable, hiding Archive also hides Unarchive, and administrators still see every control. [Commit](https://github.com/open-webui/open-webui/commit/08bfaa4be67f6f87878b2209d568a648818506a7), [#32830](https://github.com/open-webui/open-webui/issues/32830) +- 🫙 **Unavailable options hidden.** Options that cannot take effect are now hidden, such as Capture in the + menu without a Vision model, Attach Notes for users without the Notes permission, the Valves pickers in Chat Controls for disabled tools and functions, the Context Compaction Threshold while context compaction is off, the title, follow-up and tag generation switches in Settings > Interface while the admin has turned them off, Compress Images in Channels for users who cannot use channels, the Web Search in Chat setting while web search is not available to the user, the Voice section of Settings > Interface for users who cannot make calls, group and default permission toggles for features the admin has turned off, the Terminal capability and default terminal in the model editor while no terminal server or direct integration is available, the New Temporary Chat and Delete Chat shortcuts for users who may not use them, the Web Search, Image Generation, Code Interpreter and Memory capabilities and Default Features in the model editor while that feature is off for the instance, built-in tools in the model editor that the model's capabilities rule out or whose feature is off, the engine settings in Admin Settings > Web Search and Admin Settings > Images while web search or image generation is off, the Web Search Query Generation switch in Admin Settings > Interface while web search is off, the Regenerate Menu and terminal settings in Settings > Interface while they cannot apply, "/skills:create" for models that cannot create skills, the Call button while speech-to-text is unavailable, the Audio settings tab for users allowed no speech or calls, the multi-factor authentication section while MFA does not apply to the user and the openwebui.com author link on a new chat while community sharing is off, while saved values are kept for when they apply again. [#32969](https://github.com/open-webui/open-webui/pull/32969), [#32967](https://github.com/open-webui/open-webui/pull/32967), [#32959](https://github.com/open-webui/open-webui/pull/32959), [Commit](https://github.com/open-webui/open-webui/commit/9e5a355fd2b318cf68726c69dbf37dae5e8413b0), [Commit](https://github.com/open-webui/open-webui/commit/9faca2ac5bd69c536e4a87563af39a195d6ea32e), [Commit](https://github.com/open-webui/open-webui/commit/4324573f16b5ce888c9baf235188d7bcfe4b5f51), [#32962](https://github.com/open-webui/open-webui/pull/32962), [#32973](https://github.com/open-webui/open-webui/pull/32973), [#32976](https://github.com/open-webui/open-webui/pull/32976), [#32977](https://github.com/open-webui/open-webui/pull/32977), [#32980](https://github.com/open-webui/open-webui/pull/32980), [#32961](https://github.com/open-webui/open-webui/pull/32961), [Commit](https://github.com/open-webui/open-webui/commit/1f737b894827d19b1b1b4e9dfd5eea23992a3a96), [Commit](https://github.com/open-webui/open-webui/commit/be8f2c0a21d7eb3c873c085f90273dc5e6e39b3d), [Commit](https://github.com/open-webui/open-webui/commit/fbad6fb252f6eb7ccce86944b3bb83623f2d5598), [Commit](https://github.com/open-webui/open-webui/commit/11406c1654794d153f120899f12728cf84490488), [Commit](https://github.com/open-webui/open-webui/commit/35ee76cebb523c21703b31a2cf4347e984490a95), [Commit](https://github.com/open-webui/open-webui/commit/502af86b254b3d071e6b906223493f486cc73c51), [#32972](https://github.com/open-webui/open-webui/pull/32972), [Commit](https://github.com/open-webui/open-webui/commit/521da58ff3bcbb3205e695286ff8f71a64d23606), [#32975](https://github.com/open-webui/open-webui/pull/32975), [Commit](https://github.com/open-webui/open-webui/commit/d48b5e1c878140ce04b5cf5c30d15a472cbd9346), [#32983](https://github.com/open-webui/open-webui/pull/32983), [Commit](https://github.com/open-webui/open-webui/commit/8852d460b7009f776a131e88cbf0cbad595a16d8), [#32966](https://github.com/open-webui/open-webui/pull/32966), [Commit](https://github.com/open-webui/open-webui/commit/5e62c8997f9d14ebc321022a552380f4dc932526), [#32978](https://github.com/open-webui/open-webui/pull/32978), [Commit](https://github.com/open-webui/open-webui/commit/159a29ed06c4e689c465c105c74cf0d9ff4904b9), [#32963](https://github.com/open-webui/open-webui/pull/32963), [Commit](https://github.com/open-webui/open-webui/commit/98f23b17a5755216e31c09ee938e36e46d139fee), [#32965](https://github.com/open-webui/open-webui/pull/32965), [Commit](https://github.com/open-webui/open-webui/commit/758e4f6cc3bc504c8e100e535694dbc93214ad31), [#32984](https://github.com/open-webui/open-webui/pull/32984), [Commit](https://github.com/open-webui/open-webui/commit/e11c1bb38c3695917038b0d79933b35886b0e70e), [#32985](https://github.com/open-webui/open-webui/pull/32985), [Commit](https://github.com/open-webui/open-webui/commit/1d6d805587c06847d9518ccab79fe780aba8d1a6), [#32986](https://github.com/open-webui/open-webui/pull/32986), [Commit](https://github.com/open-webui/open-webui/commit/d2e88ba37abf6d83d9fcffec8574e155b352f4ca), [#32974](https://github.com/open-webui/open-webui/pull/32974), [Commit](https://github.com/open-webui/open-webui/commit/c4aac6152b59e17610bbb5aa9bab1e230460c3ed), [#32981](https://github.com/open-webui/open-webui/pull/32981), [Commit](https://github.com/open-webui/open-webui/commit/11081b1567dd4b5b41907b8733c21239079a94ca), [#32971](https://github.com/open-webui/open-webui/pull/32971), [Commit](https://github.com/open-webui/open-webui/commit/f36ed913719814419d5a621b528ec1b56595809c), [#32970](https://github.com/open-webui/open-webui/pull/32970), [Commit](https://github.com/open-webui/open-webui/commit/b02ff544597755624953ecc15290bee19348ca54), [#32964](https://github.com/open-webui/open-webui/pull/32964), [Commit](https://github.com/open-webui/open-webui/commit/2cf014c7e9be601116d8e93da0d7367f77d6f14e), [#32830](https://github.com/open-webui/open-webui/issues/32830) +- 🔏 **Access button respects sharing.** The Access button and menu entry of models, prompts, tools, knowledge, skills and notes and the Access button in folder settings are now hidden from users without the matching sharing permission, who had nothing they could change in its dialog. [#32979](https://github.com/open-webui/open-webui/pull/32979), [Commit](https://github.com/open-webui/open-webui/commit/ac12b04aada033117f9f7e8ee4fb9562c7d5a417), [#32830](https://github.com/open-webui/open-webui/issues/32830) +- 🖇️ **Attach entries without uploads.** When files cannot be attached, because of a missing permission or a selected model without File Upload, the greyed Attach Knowledge, Reference Chats and Attach Notes entries in the + menu no longer open and the # and @ lists leave out what cannot be attached, including pasted links for users without the web upload permission, so a picked item no longer vanishes from the input without a message. [#32968](https://github.com/open-webui/open-webui/pull/32968), [Commit](https://github.com/open-webui/open-webui/commit/75c4421b3ab94b1a39d74674475294571bd20bcd), [#32830](https://github.com/open-webui/open-webui/issues/32830) +- ⏱️ **Exact last active time.** Hovering the last active time in the admin user list and in a group's member list now shows the exact date and time. [Commit](https://github.com/open-webui/open-webui/commit/7484c75e163cb17fee9ebcc11e57f462289cc03e) +- 🗨️ **New chat from folders.** A folder's menu in the sidebar now has a New Chat item that starts a chat in that folder. [Commit](https://github.com/open-webui/open-webui/commit/0512f1b3763ea4e15b5adc25d087c96f728fa25e) +- 🪪 **Profile cards across the workspace.** Hovering an owner's name in the workspace lists of models, knowledge, prompts, skills and tools and in admin Functions now opens their profile card instead of an email tooltip, with their profile picture next to the name, profile pictures in access settings, version history and the Analytics user list open the card too and the Notes list shows each owner's profile picture. [Commit](https://github.com/open-webui/open-webui/commit/02974f41e9b7c9d34f2cd7c664c2a04fd6ac12ae), [Commit](https://github.com/open-webui/open-webui/commit/72cc79b8f336a753b6e75e0f3415630601b14759), [Commit](https://github.com/open-webui/open-webui/commit/ba34b1c47c1d054d2f9c498d0aed862ec06da41a), [Commit](https://github.com/open-webui/open-webui/commit/4491e60a296c89b11f22a43f416ea04770fd687f), [Commit](https://github.com/open-webui/open-webui/commit/0de8d03c625739ed3fe5e5f2a33de79a760238bd), [Commit](https://github.com/open-webui/open-webui/commit/0628670f56133d2cdc20c59b06fbb9de36932eb9) +- 🔢 **Turn count in chat previews.** The preview shown when hovering a chat in the sidebar now shows how many user and assistant turns its current branch has. [Commit](https://github.com/open-webui/open-webui/commit/b5046f9d8686e18450a200dccda181f2ac7f97c9) +- 🎚️ **Chat Controls saved per setting.** A change in a chat's Controls, such as its system prompt or a parameter, now saves only that setting, so a copy of the chat open in another tab or on another device no longer wipes it with older settings. [Commit](https://github.com/open-webui/open-webui/commit/b4a58be193031827f2fc19b943646136d1e43575), [#32755](https://github.com/open-webui/open-webui/issues/32755) +- 🪃 **Questions survive reconnects.** When the page reloads or loses its connection while Ask User, a tool approval, an MCP server's input request or a function's confirmation or input dialog waits for you, the question now comes back once you reopen that chat within a minute, so the request no longer waits without an answer. [Commit](https://github.com/open-webui/open-webui/commit/c596d40bd4f554057738ba779b38781a7c2de108) +- ✍️ **Note edits by text.** The model can now edit a note by finding and replacing exact text instead of only by character offsets, picking one match or all of them and getting the positions and surrounding text back when a match is ambiguous. [Commit](https://github.com/open-webui/open-webui/commit/243ef36c496714d9dd855de9fd261daa43caddb9), [#32752](https://github.com/open-webui/open-webui/issues/32752) +- 🔍 **Compare view stays aligned.** Matching rows in the two panes of the Compare view now stay lined up and in place while scrolling, resizing or editing, and a colored bar now marks changed lines also while whitespace or emphasis markers are ignored. [Commit](https://github.com/open-webui/open-webui/commit/32170b96fc5edc8c324c5de188be36e9d32059a2), [Commit](https://github.com/open-webui/open-webui/commit/596cdbb6caf6022947285722949de1f52272b673), [Commit](https://github.com/open-webui/open-webui/commit/5c9865a1f119d33ab11d77e79d8c8c8fb93704ec), [Commit](https://github.com/open-webui/open-webui/commit/aa8ce52c7a3afbb164bc16a3b7c20e40ba6390ed) +- 🖱️ **Clickable setting labels.** Clicking the label of a setting across user and admin settings, including advanced parameters and connections, now toggles its switch, focuses its field or presses its button, and the label highlights while the row is hovered or focused, so the right control is easier to find. [Commit](https://github.com/open-webui/open-webui/commit/33e4c5f514d57ca8f654c1015dfb32774b833db6), [Commit](https://github.com/open-webui/open-webui/commit/131ddc32ecaeb06f9aa8740ff004710bacef72e0), [Commit](https://github.com/open-webui/open-webui/commit/b93b81246817b651bb47bb1f5e453503649d30d0), [#32768](https://github.com/open-webui/open-webui/issues/32768), [#32900](https://github.com/open-webui/open-webui/issues/32900) +- ☑️ **Select several sidebar chats.** Shift-clicking a chat in the sidebar starts a selection that more clicks or a shift-click range extend, and the selected chats can then be archived or, with the delete permission, deleted together, while Escape clears the selection. [Commit](https://github.com/open-webui/open-webui/commit/acc231774d00d8856feefd2e7a3ef4b503a8224e), [#32543](https://github.com/open-webui/open-webui/issues/32543) +- 🫲 **Swipe open Chat Controls.** On a phone, swiping left across a chat now pulls in the Chat Controls panel, which follows your finger like the sidebar does and closes again with a swipe to the right. [Commit](https://github.com/open-webui/open-webui/commit/f83f9f40b3513acc82f53696aaf78a2c5d251e70) +- 🗃️ **Bulk file management.** Manage Files in Settings > Data Controls can now download a single file and lets you select several files or all files matching a search to delete them or download them together as one zip archive, with progress and a stop button. [Commit](https://github.com/open-webui/open-webui/commit/2a86805648f45c36c479c6f9a0127cae799ee14f) +- 📥 **Accurate upload status.** A file uploaded to a knowledge base now only shows as done once it is indexed and linked to the knowledge base, so it no longer shows as done and then goes missing from the list until a reload. [Commit](https://github.com/open-webui/open-webui/commit/f5e8f84e69d0f4224cc27cabd4f8d2c441d4d58c), [#32803](https://github.com/open-webui/open-webui/issues/32803) +- ⏳ **Knowledge files show Processing.** A file added to a knowledge base now switches from Uploading to Processing once it reaches the server, and the list keeps itself up to date until the file is ready, so it no longer says Uploading until you reopen the knowledge base. [#32982](https://github.com/open-webui/open-webui/pull/32982), [Commit](https://github.com/open-webui/open-webui/commit/0a568f6a813d49ca6e7797c7940647782e44ad48) +- 🧮 **Knowledge uploads listed at once.** Files selected or dropped together into a knowledge base now all appear as Uploading right away in upload order, so each one no longer waits to appear until the file before it has finished processing. [#32992](https://github.com/open-webui/open-webui/pull/32992), [Commit](https://github.com/open-webui/open-webui/commit/0c684208bbc6e28e31995c0c4788429b38048db0) +- 📈 **Analytics by tokens.** The Admin Analytics dashboard now ranks models and users by tokens by default with a switch back to messages, and its usage chart adds volume and activity views, a single model view, a comparison with the previous period, a choice of time steps and Year to date and Last 12 months ranges. [Commit](https://github.com/open-webui/open-webui/commit/131ddc32ecaeb06f9aa8740ff004710bacef72e0), [Commit](https://github.com/open-webui/open-webui/commit/06c17834ee3168b922f1b8a935b9dd76884b8fda), [Commit](https://github.com/open-webui/open-webui/commit/dc2ac86060e583f7374dd86f32f374a0b956bb8b), [Commit](https://github.com/open-webui/open-webui/commit/3539ad63213d13865a87a4db64a9829fd7d3b1b4), [Commit](https://github.com/open-webui/open-webui/commit/08bfaa4be67f6f87878b2209d568a648818506a7), [Commit](https://github.com/open-webui/open-webui/commit/06c382e5d223ba1b35dc0b1306c71da489c632b0) +- 🗂️ **File browser grid view.** The terminal file browser can now switch between a list and a grid view from its menu, folders show how many items they hold, and on larger screens hovering a file shows a preview, up to 1 MB for text files and 20 MB for images, audio, video and documents. [Commit](https://github.com/open-webui/open-webui/commit/a76c02e58ec81621cf3c6ca38eb0d09eccee694f), [Commit](https://github.com/open-webui/open-webui/commit/fefa31fae22a8eb9d39aaf17063373c27fd5a61e), [Commit](https://github.com/open-webui/open-webui/commit/fa50081e67bfa9883bbc92fd47b20d3f3974c927) +- 📁 **File browser folder menu.** A folder's menu in the terminal file browser now has New File and New Folder items that create the file or folder inside that folder. [Commit](https://github.com/open-webui/open-webui/commit/b6b0fff5d6af2fefb84f8858244adeef50462aac), [Commit](https://github.com/open-webui/open-webui/commit/fbeee59e99f47c9ff4fb0f114f78eb9dc85f2967) +- 📬 **First-message terminal uploads.** With Chat Uploads set to Filesystem on a Per chat terminal, a file attached to the first message of a new chat now waits and uploads into that chat's own terminal when the message is sent, so it no longer lands in the shared workspace. [Commit](https://github.com/open-webui/open-webui/commit/049d75e176dd1ef3f2f1eef09d63f30c4e988b3c), [#30245](https://github.com/open-webui/open-webui/issues/30245) +- 🪧 **Folder descriptions.** Folders can now have a description in their settings, shown under the folder title on the folder's page. [Commit](https://github.com/open-webui/open-webui/commit/f625c37cce4ec971e7d86af6da644fee03561b1e), [Commit](https://github.com/open-webui/open-webui/commit/72d86c6b13c1ccb567e2ffa574b3a53a853361b3) +- 🚚 **Organize chats in shared folders.** The owner of a shared folder can now rename chats other people started in it and drag them to another folder within the same shared folder. [Commit](https://github.com/open-webui/open-webui/commit/2262073146bbeda1aa4f998bb61e15ec7f9b6491) +- 🛰️ **MCP protocol 2026-07-28.** MCP connections now also work with servers that speak the stateless 2026-07-28 protocol version, while older servers still connect through the earlier handshake. [#33087](https://github.com/open-webui/open-webui/pull/33087), [Commit](https://github.com/open-webui/open-webui/commit/8151826483163877cec837b35ba7c1affdd1fb5a), [#29778](https://github.com/open-webui/open-webui/issues/29778) +- 📧 **LDAP default email domain.** Administrators can now set a default email domain in the LDAP settings or with "LDAP_DEFAULT_EMAIL_DOMAIN", so LDAP users without a mail attribute sign in with their username at that domain as their email address and into any existing account with that address, which is why the domain should be one you control. [Commit](https://github.com/open-webui/open-webui/commit/74fb7a8c56bd92e6603e71e74917a99597228d10), [#33001](https://github.com/open-webui/open-webui/issues/33001) +- 🔐 **OAuth connections over the API.** Connections set to OAuth authentication now also send the user's OAuth token when the user calls the API with an API key or bearer token, like tool servers already did. [#32826](https://github.com/open-webui/open-webui/pull/32826), [Commit](https://github.com/open-webui/open-webui/commit/6367a3cc406b3417d1a2eb2bfde9f1b841d43150), [#32820](https://github.com/open-webui/open-webui/issues/32820) +- 🫣 **Hidden base models for groups.** Administrators can now turn on the Hidden Models permission under Models Access in the user and group permissions, or set "USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN", so those users can pick hidden models as the base model of a workspace model while the hidden models stay out of their model picker. [Commit](https://github.com/open-webui/open-webui/commit/7f1b618727fe76669158bd42e2ce18fbcca63481), [#29806](https://github.com/open-webui/open-webui/discussions/29806), [#29639](https://github.com/open-webui/open-webui/issues/29639) +- 🧾 **Model usage in audit logs.** With audit logging on and "/chat" removed from "AUDIT_EXCLUDED_PATHS", where it is excluded by default, each chat reply and tool call round now writes its own audit log entry with the model and the input, output and total tokens the provider reported, linked to the request that started it. [Commit](https://github.com/open-webui/open-webui/commit/52e155019f511846a8066e012ff37f8aac6db2db), [#30098](https://github.com/open-webui/open-webui/issues/30098) +- 📨 **Voice call user headers.** With "ENABLE_FORWARD_USER_INFO_HEADERS" on, Realtime voice calls now also send the user info headers, or the signed user JWT when configured, to the realtime provider, like chat requests do. [#32733](https://github.com/open-webui/open-webui/pull/32733), [Commit](https://github.com/open-webui/open-webui/commit/c7e159bdd66a1d05331e6a579e627004bc3c51d1) +- 🏎️ **Faster action buttons.** An action button now only saves the chat again when the action changed a message, so actions on large chats finish sooner. [Commit](https://github.com/open-webui/open-webui/commit/7708ee851a1d6745a5ee3c106a0dd9f728bca732), [#32658](https://github.com/open-webui/open-webui/issues/32658), [Commit](https://github.com/open-webui/open-webui/commit/b4a58be193031827f2fc19b943646136d1e43575) +- 👪 **Faster group saves.** Saving, deleting or changing the members or permissions of a group now finishes in well under a second with thousands of users online, where it took several seconds and reloaded the open chat of everyone online. [#32883](https://github.com/open-webui/open-webui/pull/32883), [Commit](https://github.com/open-webui/open-webui/commit/b7d1de05ab91150726d83ae427f320a014ddeeec), [#32882](https://github.com/open-webui/open-webui/pull/32882), [Commit](https://github.com/open-webui/open-webui/commit/bae611e785df5feabf712ab8c8684a78bf90ec96) +- 🧳 **Faster folder changes.** Renaming, sharing, moving or deleting a folder and deleting all chats now finish quickly with thousands of users online, where each took several seconds and reloaded the open chat of everyone online. [#32888](https://github.com/open-webui/open-webui/pull/32888), [Commit](https://github.com/open-webui/open-webui/commit/e1e31973f0bfd04dc4195c896eff3476678d2005) +- 📣 **Faster channel posts.** Posting a message in a channel with many members online now returns in a fraction of a second, where it waited for every online member's login to be checked again first. [#32889](https://github.com/open-webui/open-webui/pull/32889), [Commit](https://github.com/open-webui/open-webui/commit/a9adb2b78ebccdd51543a47c285309e9b016a71c) +- 📊 **Faster analytics dashboard.** The Admin Analytics dashboard now gets its totals, model and user lists and daily charts from fewer and lighter database queries, so it opens faster on instances with many messages. [Commit](https://github.com/open-webui/open-webui/commit/97650bbbea43b98c1c403bf74b08736e9ad522d7) +- ♻️ **Prompt cache survives restarts.** Tools from tool servers, Open Terminal included, now reach the model in the same form after every restart, so the provider's prompt cache keeps working and long chats are no longer processed again from scratch. [#33000](https://github.com/open-webui/open-webui/pull/33000), [Commit](https://github.com/open-webui/open-webui/commit/ee7c9ff5c19d7d8c24c991028b08ff6672c6e653) +- 👁️ **PDF image OCR stays light.** With PDF Extract Images (OCR) on, reading text from images in PDFs now uses one processor thread per upload by default, so uploads no longer start a thread pool the size of the server's cores that could slow the whole server down, and administrators can pass their own RapidOCR settings with "RAPIDOCR_PARAMS". [Commit](https://github.com/open-webui/open-webui/commit/56c570afc36e6dae528563059e30b1e030628734), [#32844](https://github.com/open-webui/open-webui/issues/32844) +- ♿ **Improved UI accessibility.** Screen reader users now hear the Send button, the sidebar's user menu button by the name it shows, Playground's role button by the word it shows, the model editor's Description, Tools, Skills, Filters and Actions rows by what they show, a sidebar folder's options button, the sidebar button on Calendar, Notes, Channels and Home on phones, the on/off switches on Skills, Functions and Automations rows, the suggested prompts on a new chat as buttons, Archived Chats as a table, the sort order on the column headers of Archived Chats and the Evaluations leaderboard, chat input pills and menus, copy, export, merge and artifact buttons, clear search and Cancel buttons, the note editor's Undo, Redo, Record and Note Menu buttons, Playground's System Instructions button and model selects, the code box in the tool and function editors, switches, icon buttons, selects and number inputs in Admin Settings, the Create knowledge base, Create Prompt and Clone Prompt dialogs and the close button of 22 dialogs by name, together with a source's number, the number on a channel's member count button, the choice a Visibility button shows, a resizable panel's width, how long ago each sidebar chat was updated, the info button under a reply with its usage figures, a model's description, notifications such as copy confirmations (copying a prompt in Workspace included), when a reply starts and finishes, call mode's Listening and Thinking status, menu buttons as menus with their open state, the selected search content type, how many archived chats a search found, how many models match a search in the model picker, the Settings window by name, the sidebar folder toggle by name with its open state, the chosen option in Select dropdowns, the search window's list by its visible caption, a heading on each Workspace page, the sidebar section titles and chat date groups as headings, the sidebar's models, folders and chats as lists, the Settings tabs as Personal and Admin tab lists that point to their panels, the items of the input, integrations, user and chat menus as menu items, the Keyboard tab's shortcut categories as headings with a list under each, the chat input as a text field, the sidebar logo link as New Chat, the onboarding logo by the instance name with other branding images hidden as decoration, the model logos on a new chat by model name (hidden when only one model is selected), the model the arrow keys are on in the model picker, the model name and the Suggested label on a new chat as headings, the suggestion list by name and the chat title as the page heading, and no longer hear an open or closed state on the area around the sidebar button in Channels on phones; keyboard users get the model picker's More and Eject buttons that show on focus, filter lists that move focus to their options when opened, tooltips that show on focus and close with Escape, Escape in the model picker's More menu or filter list that closes only that list, focus that returns to the model picker button when the picker closes with Accessibility Mode on, an outline on the model picker row the arrow keys are on, focus that moves to the other sidebar button when the sidebar opens or closes, arrow keys that move through the input, integrations, user, chat and Terminal menus, a Terminal menu that opens on its first terminal, a Tab key that reaches the note editor's Record and Note Menu buttons, Evaluations leaderboard columns they can sort and models they can open from it, focus that stays on a Workspace section link after choosing it, focus that stays in the Archived Chats list after unarchiving a chat, focus on the recording bar when dictation starts and a Skip to main content link that now moves focus to the page content; notification text in the light theme and the chat dates in Archived Chats now meet the 4.5:1 contrast minimum; buttons in the collapsed sidebar, on sidebar folders, on Prompts, Skills, Functions and Automations rows, on Workspace and Notes list rows, on attached files and in the Integrations menu are no longer nested inside other buttons; the Archived Chats and Keyboard lists stay usable at 400% zoom; and with Accessibility Mode on, pinch zoom works on phones, the active Workspace section is underlined, code editor placeholders, the model editor's JSON Preview row and Calendar's days from other months and today's date meet the contrast minimum, and the More and Eject buttons and Set as default in the model picker, the version link and See what's new in Settings > About, the sidebar's Chat Menu button, the Note Menu and Delete buttons in Notes, the More button on file rows, the Create button's menu arrow, Calendar's previous and next month buttons and the name, ID and command fields in the model and prompt editors are at least 24 pixels in size. +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Translations for Korean, Cebuano, Thai, German, Japanese, Vietnamese, Urdu, Bengali, Hungarian, Tibetan, Turkmen, Lithuanian, Danish, Basque, Irish, Slovenian, Estonian, Croatian, Faroese, Georgian, Portuguese (Brazil), Spanish (Spain), Hindi, Finnish, Kabyle, Galician, Uyghur, Slovak, Norwegian Bokmål, Serbian, Ukrainian, Polish, Turkish, Bosnian, Dutch, Czech, Bulgarian, Hebrew, Punjabi, Swedish, Romanian, Persian, Greek, Malay, Latvian, Azerbaijani, Indonesian, Tamil, Filipino and Russian were enhanced and expanded. + +### Fixed + +- 🛡️ **Security Advisory**: This release contains **security fixes**. We **strongly recommend updating** all deployments **as soon as possible** to have the latest security fixes in place. These fixes are **not listed in this changelog**; we will publish the security advisories for them immediately following CVE assignment. [Advisories](https://github.com/open-webui/open-webui/security) +- 📤 **Chat export permission respected.** The Export item of a folder in the sidebar, the Export option in Archived Chats and the Copy option in the chat menu are now hidden from users without the Allow Chat Export permission, like the export options of single chats. [#32703](https://github.com/open-webui/open-webui/pull/32703), [Commit](https://github.com/open-webui/open-webui/commit/a21335bdd44892a804d74614567b2c64a19e0a11), [#32702](https://github.com/open-webui/open-webui/issues/32702), [#32684](https://github.com/open-webui/open-webui/pull/32684), [Commit](https://github.com/open-webui/open-webui/commit/c9936e81ecb8d50187a9c31d2952353da2b538c8), [#32683](https://github.com/open-webui/open-webui/issues/32683), [#32960](https://github.com/open-webui/open-webui/pull/32960), [Commit](https://github.com/open-webui/open-webui/commit/b17ee7d83925c87688f308e4599dbd320a017fe4) +- 🎛️ **Pipe Valves permission respected.** The Valves button of a pipe model in the chat input is now hidden from users without the Allow Chat Valves permission. [#32772](https://github.com/open-webui/open-webui/pull/32772), [Commit](https://github.com/open-webui/open-webui/commit/46cdd70d7be82bbc25952a37fd6f647d7ea627cc), [#32771](https://github.com/open-webui/open-webui/issues/32771) +- 👥 **One model without Multiple Models.** A new chat now starts with a single model for users without the Multiple Models permission, where a default or a link with several models kept them all selected. [#32668](https://github.com/open-webui/open-webui/pull/32668), [Commit](https://github.com/open-webui/open-webui/commit/5d75c1905b79a6a0e9dd505cd1fc2db6a5502849), [#32667](https://github.com/open-webui/open-webui/issues/32667) +- 🖊️ **Chat Edit permission respected.** The Edit button of a user's own messages is now hidden from users without the Allow Chat Edit permission, where only the Edit button of replies was hidden. [#32841](https://github.com/open-webui/open-webui/pull/32841), [Commit](https://github.com/open-webui/open-webui/commit/ce7f87a7505c911da1fab5309692a7329bf0c395), [#32832](https://github.com/open-webui/open-webui/issues/32832) +- 📅 **Calendar page respects access.** Opening the Calendar page with Calendar turned off or without the Calendar permission now returns to the home page, where it showed an empty calendar and an error. [#32837](https://github.com/open-webui/open-webui/pull/32837), [Commit](https://github.com/open-webui/open-webui/commit/ffcb24824f6b4afacb4e36147eb3b701b99441db), [#32834](https://github.com/open-webui/open-webui/issues/32834) +- 🔒 **Live access after archiving.** People who see a chat through a shared folder now stop receiving its live updates when the owner archives it or archives all chats, and people with a share link stop when the owner unshares all chats, as they already did when a single chat was unshared or deleted. [#32887](https://github.com/open-webui/open-webui/pull/32887), [Commit](https://github.com/open-webui/open-webui/commit/ed3cf8355346689d790829074aa7d8cd004b7865) +- 🧵 **Thread replies deleted too.** Deleting a channel message now also deletes its thread replies, which stayed behind and could still be found in search. [#32666](https://github.com/open-webui/open-webui/pull/32666), [Commit](https://github.com/open-webui/open-webui/commit/54fc21d6855ba4c41c9088c758949d6fcc0ff26f), [#32665](https://github.com/open-webui/open-webui/issues/32665) +- 🔗 **Shared chat pages load once.** Chats opened under /s/, through a share link or by an administrator viewing another user's chat, no longer reload over and over and jump back to the bottom, which since 0.12.3 made scrolling up impossible. [#32725](https://github.com/open-webui/open-webui/pull/32725), [Commit](https://github.com/open-webui/open-webui/commit/c4153a6eeaeb6a0c5ec8cc152a1bdf328b848859), [#32657](https://github.com/open-webui/open-webui/issues/32657) +- 🧹 **Deleting the open chat.** Deleting the chat you are viewing from the sidebar now opens an empty new chat, where the deleted chat's messages stayed on screen and the next message failed, in Safari with "The string did not match the expected pattern." [#33069](https://github.com/open-webui/open-webui/pull/33069), [Commit](https://github.com/open-webui/open-webui/commit/c04e408432b24e2a6cbeb2eaf513b8f2840460b6), [#33068](https://github.com/open-webui/open-webui/issues/33068) +- 🪡 **More Concise and Add Details.** Choosing More Concise or Add Details or typing a suggestion under a reply now rewrites that reply, where the model got the instruction without the reply and answered it on its own, for example with "OK, I'll keep answers shorter". [#33084](https://github.com/open-webui/open-webui/pull/33084), [Commit](https://github.com/open-webui/open-webui/commit/c69e5a73c0de43691e502bfaa03285e4e8cec05d), [#33083](https://github.com/open-webui/open-webui/issues/33083) +- 📱 **Sign-in page on small screens.** The sign-in page can now be scrolled when it is taller than the screen, such as on phones with the keyboard open, where parts of the form could be out of reach. [Commit](https://github.com/open-webui/open-webui/commit/ffbe0ac12da8ca5af9da12f8f00c43e382300b4f) +- 🖼️ **Note menu image uploads.** Images picked with Upload files in a note's menu now appear in the note, where they were saved with the note's files but never inserted. [#32793](https://github.com/open-webui/open-webui/pull/32793), [Commit](https://github.com/open-webui/open-webui/commit/4604f2a3bc2f112135eac75c06048916d3ff37df), [#32792](https://github.com/open-webui/open-webui/issues/32792) +- 📜 **Prompt history on unchanged saves.** Saving a prompt without changes no longer adds an identical version to its history. [#32709](https://github.com/open-webui/open-webui/pull/32709), [Commit](https://github.com/open-webui/open-webui/commit/312d113fd04bc43996cf97282c34b46ba79c26cd), [#32708](https://github.com/open-webui/open-webui/issues/32708) +- 🎨 **Images after tool approval.** Images the model generates after you answer its question or approve or reject a tool call now appear in the reply, where they were created but never shown and the model kept retrying with more hidden images. [#32680](https://github.com/open-webui/open-webui/pull/32680), [Commit](https://github.com/open-webui/open-webui/commit/93ba628f3a3cf7f275892f3f3e0375d5984b51a7), [#32678](https://github.com/open-webui/open-webui/issues/32678) +- 🧬 **Embeddings with Prefix ID.** The embeddings API now works with models from a connection that has a Prefix ID, where the provider rejected the prefixed model name as unknown. [#32724](https://github.com/open-webui/open-webui/pull/32724), [Commit](https://github.com/open-webui/open-webui/commit/c901bc39d31eb85af289743850f92848529d422b), [#32715](https://github.com/open-webui/open-webui/issues/32715) +- 📞 **Realtime calls answer once.** A Realtime call no longer answers the same sentence twice when a short pause splits it into two turns. [#32773](https://github.com/open-webui/open-webui/pull/32773), [Commit](https://github.com/open-webui/open-webui/commit/25b115662396a9e09ee115008f4516aaa2eebd1d), [#32764](https://github.com/open-webui/open-webui/issues/32764) +- ⬅️ **Right-to-left question cards.** Question cards from Ask User now follow the Chat Direction setting, so Hebrew and Arabic questions and answers are laid out right to left. [#32784](https://github.com/open-webui/open-webui/pull/32784), [Commit](https://github.com/open-webui/open-webui/commit/aee4cbf4639281f711018af08e029e0f501fd3c4), [#32778](https://github.com/open-webui/open-webui/issues/32778) +- 🛠️ **Same-named tool functions.** When two attached tools have a function with the same name, the model can now call both, where only the first one could be called. [#32670](https://github.com/open-webui/open-webui/pull/32670), [Commit](https://github.com/open-webui/open-webui/commit/ab6976b5bf6b1f18149a4f6c8ec4e2d90adec643), [#32669](https://github.com/open-webui/open-webui/issues/32669) +- 🧷 **Tool server array parameters.** An array query parameter now reaches an OpenAPI tool server as repeated values instead of one text value that looks like a Python list. [#32677](https://github.com/open-webui/open-webui/pull/32677), [Commit](https://github.com/open-webui/open-webui/commit/fd1e59b25ff68ff015ce84707839084157df6045), [#32676](https://github.com/open-webui/open-webui/issues/32676) +- 📖 **Reading files in pages.** The model's file reading tools now return every part of a file or knowledge base file read in pages with line numbers on, where some text between pages was skipped, and asking for only an end line now reads from the first line up to it, where the model got the same opening part back and kept retrying. [#33155](https://github.com/open-webui/open-webui/pull/33155), [Commit](https://github.com/open-webui/open-webui/commit/a01b36afe80983ca878b2c4dcdc25f8559a25308), [#33099](https://github.com/open-webui/open-webui/issues/33099), [#33154](https://github.com/open-webui/open-webui/pull/33154), [Commit](https://github.com/open-webui/open-webui/commit/d378fe81cb7e6bde2e4766cc5be1e0a45a320d3b), [#33100](https://github.com/open-webui/open-webui/issues/33100) +- 📚 **Multi-folder knowledge drops.** Dropping several folders on a knowledge base now uploads all of them, where only the first one was uploaded. [#32653](https://github.com/open-webui/open-webui/pull/32653), [Commit](https://github.com/open-webui/open-webui/commit/d907b61816018202d2425109fbf9b506107e7182), [#32652](https://github.com/open-webui/open-webui/issues/32652) +- 🙈 **Base models after publishing.** Hiding, toggling or editing a base model right after making it public in Admin Settings > Models is now saved. [#32655](https://github.com/open-webui/open-webui/pull/32655), [Commit](https://github.com/open-webui/open-webui/commit/d8bef1ef90391647d2d34dffade63484f70e5c83), [#32654](https://github.com/open-webui/open-webui/issues/32654) +- 🧩 **Restored skill-only drafts.** A draft that holds only a skill, brought back to the chat input, now sends only that skill, where the names of every skill were sent. [#32690](https://github.com/open-webui/open-webui/pull/32690), [Commit](https://github.com/open-webui/open-webui/commit/14956af03f2ba50a154b13ff77166139fdcc0315), [#32689](https://github.com/open-webui/open-webui/issues/32689) +- 🐍 **Python code block results.** Running a plotting block again now shows only the new plot, and a block whose last value is a date, decimal number or other object shows that value instead of "Maximum call stack size exceeded". [#32686](https://github.com/open-webui/open-webui/pull/32686), [Commit](https://github.com/open-webui/open-webui/commit/1a0bdb38a179ace36419aee87688727a9466a25a), [#32685](https://github.com/open-webui/open-webui/issues/32685), [#32682](https://github.com/open-webui/open-webui/pull/32682), [Commit](https://github.com/open-webui/open-webui/commit/fb5eec56dd2212d07a77dfb4ed4b25d26ccebcb2), [#32681](https://github.com/open-webui/open-webui/issues/32681) +- 🪟 **Preview of an earlier artifact.** Preview on an earlier code block now opens that block's artifact when the artifacts pane was closed, where it opened the newest one. [#32688](https://github.com/open-webui/open-webui/pull/32688), [Commit](https://github.com/open-webui/open-webui/commit/9862a5bd613bb1c98166bb0eff1c8e824fa86723), [#32687](https://github.com/open-webui/open-webui/issues/32687) +- 📎 **Attachments in the chat input.** An attached webpage now stays when file upload is not allowed, and a file's Entire Document switch shows that file's own setting after another attachment is removed. [#32701](https://github.com/open-webui/open-webui/pull/32701), [Commit](https://github.com/open-webui/open-webui/commit/bb8d49ff31d3503f91dff2d6e60b4376e021d2f7), [#32700](https://github.com/open-webui/open-webui/issues/32700), [#32699](https://github.com/open-webui/open-webui/pull/32699), [Commit](https://github.com/open-webui/open-webui/commit/a944564c20a3c207c6987e49d10cc01658e50b67), [#32698](https://github.com/open-webui/open-webui/issues/32698) +- 🕸️ **Web pages on fresh installs.** Attached web pages and other HTML text extraction return their text again on fresh installs, where a broken update of a library underneath made them come back empty. [#33103](https://github.com/open-webui/open-webui/pull/33103), [Commit](https://github.com/open-webui/open-webui/commit/f32de07ddead5453658c828cd7e2c3a1c680934c) +- 🔎 **Shared Chats search paging.** A search in Shared Chats or in an admin's view of a user's chats now loads more than the first 60 results. [#32675](https://github.com/open-webui/open-webui/pull/32675), [Commit](https://github.com/open-webui/open-webui/commit/0f2d1af1ce2092b2d6021823a923ddd179682f52), [#32674](https://github.com/open-webui/open-webui/issues/32674) +- 📂 **Open folder stays open.** Deleting another folder in the sidebar no longer closes the folder page you have open. [#32697](https://github.com/open-webui/open-webui/pull/32697), [Commit](https://github.com/open-webui/open-webui/commit/db9aa8c9c02f841f4d35f65ee061eddd2e4a57f9), [#32696](https://github.com/open-webui/open-webui/issues/32696) +- 📝 **Note pins and counts.** The Notes list now shows Pin for a note unpinned from the sidebar, and readers of a note shared as read only see its word and character counts instead of zero. [#32707](https://github.com/open-webui/open-webui/pull/32707), [Commit](https://github.com/open-webui/open-webui/commit/6a7205e965072a016c9c3046e1be320921a0af5f), [#32706](https://github.com/open-webui/open-webui/issues/32706), [#32711](https://github.com/open-webui/open-webui/pull/32711), [Commit](https://github.com/open-webui/open-webui/commit/252338964223cc75b5d0f02ba9e3cb7f6bd9ab90), [#32710](https://github.com/open-webui/open-webui/issues/32710) +- 🪞 **Notes open once.** A note that two people open at the same time, such as one imported from a Markdown or text file and not edited since, no longer shows and saves its text twice, also with several workers. [#33004](https://github.com/open-webui/open-webui/pull/33004), [Commit](https://github.com/open-webui/open-webui/commit/499b83fa8d47f592bd7d79cab270d5cfc12652be), [#33002](https://github.com/open-webui/open-webui/issues/33002), [Commit](https://github.com/open-webui/open-webui/commit/1407f83cfc8df4e727f82beabdd4da6e29fc1119), [#32824](https://github.com/open-webui/open-webui/issues/32824) +- 🔊 **Voice from Settings > Audio.** Read Aloud and voice mode now use the voice picked in Settings > Audio, which in Firefox fell back to the default voice. [#32705](https://github.com/open-webui/open-webui/pull/32705), [Commit](https://github.com/open-webui/open-webui/commit/5edbd12bc9ba29eb0753d9bc888165d23171b0d8), [#32704](https://github.com/open-webui/open-webui/issues/32704) +- 🔈 **Read Aloud stops on failure.** Read Aloud now stops requesting the remaining sentences once one fails, so an unavailable speech provider shows a single error instead of one for every sentence. [#33028](https://github.com/open-webui/open-webui/pull/33028), [Commit](https://github.com/open-webui/open-webui/commit/ea615c3a2e89d827e24016d719acb5ee63f409d2), [#33023](https://github.com/open-webui/open-webui/issues/33023) +- 🗄️ **Archived chats survive folders.** Archive All Chats now takes the chats out of their folders, so deleting a folder afterwards no longer deletes those archived chats for good. [#32647](https://github.com/open-webui/open-webui/pull/32647), [Commit](https://github.com/open-webui/open-webui/commit/97f5c4e973a703b63a2606b054de324db88f706f), [#32646](https://github.com/open-webui/open-webui/issues/32646) +- 🔌 **No hangs after closed pages.** Closing a page while it was still loading, such as a chat export or a file's processing status, no longer loses database connections on SQLite, where later requests could hang or fail with 503 once enough were lost. [#32617](https://github.com/open-webui/open-webui/pull/32617), [Commit](https://github.com/open-webui/open-webui/commit/2355b8a3699826eaf1fe5a344f63e292f9532c48), [#32609](https://github.com/open-webui/open-webui/issues/32609), [#32741](https://github.com/open-webui/open-webui/issues/32741) +- 🧱 **Frontend build on Windows.** Running npm run build or npm run dev on Windows works again, where the encryption module check added in 0.12.3 failed on Windows paths and line endings, and the build now runs with a 12 GB memory limit like the Docker build. [#32735](https://github.com/open-webui/open-webui/pull/32735), [Commit](https://github.com/open-webui/open-webui/commit/7345c6c31e91b473a4a6b49ec14d37d37cbd8b49), [#32656](https://github.com/open-webui/open-webui/issues/32656) +- 🪄 **Function input dialogs answered.** Confirming or cancelling a function's input or confirmation dialog now reaches the function when a script event arrived while the dialog was open, where the function kept waiting for an answer. [#32744](https://github.com/open-webui/open-webui/pull/32744), [Commit](https://github.com/open-webui/open-webui/commit/2d9852c6048774c0d8913c722b5fe661326f1b4d), [#32742](https://github.com/open-webui/open-webui/issues/32742) +- ❓ **Clearer Ask User error.** When a model sends the Ask User questions as something other than a list, such as a JSON string, it now gets an error saying they must be an array of question objects instead of "ask_user requires 1-3 questions", which made models repeat the same call. [#32727](https://github.com/open-webui/open-webui/pull/32727), [Commit](https://github.com/open-webui/open-webui/commit/794e57cf7ed29e0bf00a66a825c1d0b9682845b4), [#32662](https://github.com/open-webui/open-webui/issues/32662) +- 🛑 **Stop sequences with percent.** A stop sequence containing % no longer stops every message from sending. [#32649](https://github.com/open-webui/open-webui/pull/32649), [Commit](https://github.com/open-webui/open-webui/commit/fdb304be2f5250c9282bacd3ff91757e55b89976), [#32648](https://github.com/open-webui/open-webui/issues/32648) +- 🗜️ **Channel image compression by default.** With image compression on, images in channels are now compressed when Compress Images in Channels is left at its default, where they were sent uncompressed. [#32693](https://github.com/open-webui/open-webui/pull/32693), [Commit](https://github.com/open-webui/open-webui/commit/70fb5462eab6b71e258c9fbd9ce5714bd173bcfb), [#32692](https://github.com/open-webui/open-webui/issues/32692) +- 🎙️ **Microphone off when leaving.** Leaving a chat while dictating now turns the microphone off. [#32640](https://github.com/open-webui/open-webui/pull/32640), [Commit](https://github.com/open-webui/open-webui/commit/636b631ab7cf2f4053c43466468cd4bb119dd250), [#32638](https://github.com/open-webui/open-webui/issues/32638), [#32639](https://github.com/open-webui/open-webui/issues/32639) +- ✏️ **Chat titles of only spaces.** Renaming a chat to only spaces now keeps its title and shows an error instead of saving a blank title. [#32770](https://github.com/open-webui/open-webui/pull/32770), [Commit](https://github.com/open-webui/open-webui/commit/9cbd27011b1d1842f52cbf91b4b5764b5820b52b), [#32769](https://github.com/open-webui/open-webui/issues/32769) +- 💬 **Prompts from / in channels.** Picking a prompt from the / menu in a channel now inserts the prompt text instead of a command chip. [#32713](https://github.com/open-webui/open-webui/pull/32713), [Commit](https://github.com/open-webui/open-webui/commit/1d0b78271910cb5a20e121fd586389906b87dbbe), [#32712](https://github.com/open-webui/open-webui/issues/32712) +- 🔤 **No stray formatting spaces.** Chat messages no longer show an extra space at the start of each paragraph and before italic, bold, struck-through or linked text, which appeared in 0.12.3 and left a gap after an em dash. [#32743](https://github.com/open-webui/open-webui/pull/32743), [Commit](https://github.com/open-webui/open-webui/commit/2bebbb44e1a04b220f831aced950b0e334529988), [#32739](https://github.com/open-webui/open-webui/issues/32739) +- 🔁 **FastMCP list results sent once.** List results from tools on servers built with the standalone FastMCP package now reach the model once instead of twice, as text and as structured data. [#32726](https://github.com/open-webui/open-webui/pull/32726), [Commit](https://github.com/open-webui/open-webui/commit/217766ed0f53bde167cb80b115f7eb79664ffe5a), [#32719](https://github.com/open-webui/open-webui/issues/32719) +- 🪢 **MCP tools with alternative inputs.** MCP tools that describe their input as alternatives, such as a page by ID or by path, now receive the arguments the model sends, where they were called with none and the server rejected the call. [#32903](https://github.com/open-webui/open-webui/pull/32903), [Commit](https://github.com/open-webui/open-webui/commit/f3f9af594f3c9a20b1f46f846eb8393630733f6c), [#32898](https://github.com/open-webui/open-webui/discussions/32898) +- 🩺 **Readable MCP tool errors.** When an MCP tool returns an error, the model now gets the server's error text as written instead of an unreadable dump of the whole result, so it can correct the call where it tended to retry the same broken one. [#32942](https://github.com/open-webui/open-webui/pull/32942), [Commit](https://github.com/open-webui/open-webui/commit/37af63b6496d545f6f2d4e8067ea01de209e218b), [#32918](https://github.com/open-webui/open-webui/issues/32918) +- 📞 **Ended Realtime calls named correctly.** When the voice provider ends a Realtime call, the call now shows "Voice provider connection closed" instead of sometimes showing "Voice connection failed". [#32730](https://github.com/open-webui/open-webui/pull/32730), [Commit](https://github.com/open-webui/open-webui/commit/cd74e6d1266c5a623f9c143d7efa6cb4d6f3f0c9), [#32729](https://github.com/open-webui/open-webui/issues/32729) +- 🧭 **Scrolling while a reply streams.** On slow or busy devices a streaming reply now keeps the view at the bottom as its content grows, where it stopped following partway through, and scrolling up while it streams is no longer pulled back down. [Commit](https://github.com/open-webui/open-webui/commit/5a46aad0f0702536c98c9a26ae7d04482cdac784), [#32728](https://github.com/open-webui/open-webui/issues/32728) +- ⚡ **Early updates in new chats.** The first reply of a new chat no longer stays empty with the Stop button showing when its error or content arrives before the page knows the new chat, a message added while a chat is still loading no longer stays missing until a reload, and a message queued during the first reply of a new chat is sent once that reply finishes. [Commit](https://github.com/open-webui/open-webui/commit/cbd06d2aad0d64c2a50b4989c4290762de2cd3f0), [Commit](https://github.com/open-webui/open-webui/commit/d5038fec44d2f37e81798a6c7f4340a8d1a62f10), [#32550](https://github.com/open-webui/open-webui/issues/32550), [#32761](https://github.com/open-webui/open-webui/issues/32761) +- 🧠 **Reasoning replay with Azure.** Reasoning models on Azure OpenAI through the Responses API no longer fail with an "Unknown parameter" error on the request after they think and then call a tool. [Commit](https://github.com/open-webui/open-webui/commit/d1705c10788242b4d1a49042318a577877042469), [#32805](https://github.com/open-webui/open-webui/issues/32805) +- 📋 **Drafts with variables kept.** Reloading the page with a chat input draft that contains {{ }} placeholders, or editing a queued message that contains them, now keeps the text as it was, where it asked for input variables and dropped the text. [Commit](https://github.com/open-webui/open-webui/commit/6dbd375c267dd0dafc83c480ec23e3b1a7156406), [#32650](https://github.com/open-webui/open-webui/issues/32650) +- 🔑 **OAuth 2.1 static MCP secrets.** MCP connections using OAuth 2.1 (Static) now send the client secret through HTTP Basic authentication when the server does not take it as a form field, where servers such as X Ads that list the public client method first got no secret and rejected the sign-in. [#32818](https://github.com/open-webui/open-webui/pull/32818), [Commit](https://github.com/open-webui/open-webui/commit/21f770fb731ff24245bc9ca8c3c82bb4171e24cf), [#32813](https://github.com/open-webui/open-webui/issues/32813) +- 🌍 **Web Search settings save.** Admins can now save Web Search settings such as the web loader while web search is turned off, where empty search engine fields blocked saving. [#32840](https://github.com/open-webui/open-webui/pull/32840), [Commit](https://github.com/open-webui/open-webui/commit/cc6de63bc876f3c3d8116d5d2858e7887b1997b9), [#32831](https://github.com/open-webui/open-webui/issues/32831) +- 🖌️ **Images settings save.** Admins can now save Images settings while Image Generation or Image Edit is turned off, where a missing API key or URL for that feature's engine, such as the OpenAI key on a fresh install, blocked every change on the page. [#32949](https://github.com/open-webui/open-webui/pull/32949), [Commit](https://github.com/open-webui/open-webui/commit/6b9237f1a1ef28045d36c07eb452e7b76524c19d), [#32835](https://github.com/open-webui/open-webui/issues/32835) +- 🌄 **WebP image editing.** Editing a WebP image with Image Edit now works in the Docker image, where the image reached the provider without an image type and was rejected. [#32902](https://github.com/open-webui/open-webui/pull/32902), [Commit](https://github.com/open-webui/open-webui/commit/f6f42b0111054123d6811270feca35bf9ce6d19d), [#32899](https://github.com/open-webui/open-webui/issues/32899) +- 🍴 **No fork in temporary chats.** The Fork chat option is now hidden in temporary chats and the /fork command answers that there is no chat to fork, where forking failed with an error. [#32839](https://github.com/open-webui/open-webui/pull/32839), [Commit](https://github.com/open-webui/open-webui/commit/1a492207971a5118dde2fe6b523edf7021f38cc9), [#32833](https://github.com/open-webui/open-webui/issues/32833) +- 🚀 **First message after loading.** A message sent right after the page loads is no longer wiped by the new chat setup still running, where it disappeared and its reply stayed empty until a reload. [#32829](https://github.com/open-webui/open-webui/pull/32829), [Commit](https://github.com/open-webui/open-webui/commit/4ed1af6879ee632a3130771b4a960ccf37c1c305), [#32819](https://github.com/open-webui/open-webui/issues/32819) +- 📑 **MinerU key in local mode.** Document extraction with MinerU in local mode now sends the configured API key to MinerU 4.x servers that require one, where every document failed. [#32838](https://github.com/open-webui/open-webui/pull/32838), [Commit](https://github.com/open-webui/open-webui/commit/3e0564d11a06052d8042fffc95b138dcbde5df6b), [#32836](https://github.com/open-webui/open-webui/issues/32836) +- 🚫 **Model import reports taken IDs.** Importing models on the Workspace Models page now stops with an error at a model whose ID belongs to another user's model the importer cannot edit or, for non-admins, to a connection model, where that model was skipped without a message. [#32866](https://github.com/open-webui/open-webui/pull/32866), [Commit](https://github.com/open-webui/open-webui/commit/cb7d7e851d160bfc866aca9385c8a8013dd10192) +- 🎞️ **Left-aligned PowerPoint titles.** The PowerPoint preview now keeps titles and subtitles that the file aligns left on the left, where it centered them. [#32876](https://github.com/open-webui/open-webui/pull/32876), [Commit](https://github.com/open-webui/open-webui/commit/8fa77c8282b10e7de36863c4df6583a62a55354c), [#32873](https://github.com/open-webui/open-webui/issues/32873) +- 🫥 **Multi-model hover previews.** Sidebar hover previews of a chat with replies from several models now show one reply at full width, where every reply was squeezed into the card. [Commit](https://github.com/open-webui/open-webui/commit/35b51e09eab0942470cb9dce8f97fb00b9b96f2c), [#32858](https://github.com/open-webui/open-webui/issues/32858) +- 🐚 **Launcher help in zsh.** The examples in the Docker Compose launcher help now quote options with square brackets, so they also work when pasted into zsh. [#32881](https://github.com/open-webui/open-webui/pull/32881), [Commit](https://github.com/open-webui/open-webui/commit/52f51ea4f3044072d49eefd4feedb859bb66b80c), [#32878](https://github.com/open-webui/open-webui/issues/32878) +- 🌡️ **Context gauge uses model threshold.** The context usage gauge now measures against the compaction threshold set on the model or in the default model parameters, the same one compaction uses, where it ignored a lower threshold on the model and showed a chat as far from compaction. [Commit](https://github.com/open-webui/open-webui/commit/c9415a688ea469144ab2a7796d5eef71947af0b8), [#32821](https://github.com/open-webui/open-webui/issues/32821) +- 🏷️ **Tooltips show text as written.** Tooltips now show names, paths and descriptions that contain angle brackets as written, where they came out mangled or partly missing. [#32953](https://github.com/open-webui/open-webui/pull/32953), [Commit](https://github.com/open-webui/open-webui/commit/3299a097530847ec53da01a5964cf7ef8592dee0) +- 📇 **Knowledge file text edits.** The Preview of a text file in a knowledge base now shows its indexed text and refreshes after you save an edit, where it kept showing the original upload, and saving empty content is now refused. [Commit](https://github.com/open-webui/open-webui/commit/82d6f230b4e701e85863c01b1992a25a767696bf) +- 📡 **Websockets after Redis reconnects.** With the Redis websocket manager and "retry_on_timeout" in "WEBSOCKET_REDIS_OPTIONS", an instance now accepts new browser connections again once Redis is back after its connection dropped, where since 0.12.3 every new connection could hang until the instance was restarted. [#33009](https://github.com/open-webui/open-webui/pull/33009), [Commit](https://github.com/open-webui/open-webui/commit/0d17660535dd2ad9d6f0b75a2a2f5855066cd0a0), [#33008](https://github.com/open-webui/open-webui/issues/33008) +- 🥞 **One model More menu open.** Clicking the More button of another model in the model picker, or clicking another model, now closes the More menu that was open, where the menus stayed open and piled up over the picker. [#33020](https://github.com/open-webui/open-webui/pull/33020), [Commit](https://github.com/open-webui/open-webui/commit/fa629e5ca5015f6bc3e3be1f7a4a76c5ccd57e18), [#33019](https://github.com/open-webui/open-webui/issues/33019) + +### Changed + +- 🔧 **Direct Integrations off disables connections.** While Direct Integrations is off, as it is by default, the tool servers and Open Terminal connections that users and admins saved in their Integrations settings are no longer used in chats, where turning it off only hid the settings tab, so instances that never turned it on lose those connections until an admin turns on Direct Integrations in Admin Settings > Connections or sets "ENABLE_DIRECT_INTEGRATIONS". [#32996](https://github.com/open-webui/open-webui/pull/32996), [Commit](https://github.com/open-webui/open-webui/commit/3e9d6cb9508a1ba4ec18017b3c80afaf6ac59a89) +- 🔀 **Sharing changed only in Access.** Saving a model, prompt, tool, skill, knowledge base, note, channel or calendar no longer changes who it is shared with, which is now changed only in the item's Access settings, saved right away in the model editor, channel settings and external knowledge sources, or through the access endpoints that channels and calendars now also have. [Commit](https://github.com/open-webui/open-webui/commit/5f95008abdfbbbfbe5c080de0347deb70eb507b6), [Commit](https://github.com/open-webui/open-webui/commit/c7e34e0fa9bdd34f798ab197e1e21aca2234ed2b) +- 🐳 **Python 3.12 Docker image.** The Docker image now runs Python 3.12 on Debian 13 instead of Python 3.11 on Debian 12, so custom functions, tools and packages installed with pip inside the container need versions that support Python 3.12. [#32861](https://github.com/open-webui/open-webui/pull/32861), [Commit](https://github.com/open-webui/open-webui/commit/a915df8ce12b32109b7b929cb2cad6046293aef6) +- 🤝 **Sharing permissions enforced everywhere.** Sharing permissions such as Models Sharing, Knowledge Sharing and Folders Sharing now also apply to sharing through the API, and a change in an item's Access settings that a user is not allowed to make is now refused with an error instead of being quietly left out. [Commit](https://github.com/open-webui/open-webui/commit/5f95008abdfbbbfbe5c080de0347deb70eb507b6) +- 🗄️ **Manage Files shows only yours.** Manage Files in Settings > Data Controls now lists only your own files, also for administrators, who saw every user's files there while "BYPASS_ADMIN_ACCESS_CONTROL" was on, as it is by default. [Commit](https://github.com/open-webui/open-webui/commit/2a86805648f45c36c479c6f9a0127cae799ee14f) +- 🕹️ **Model controls for all users.** Anyone with access to a model can now pick its model control options, such as a Thinking level, and parameters saved to an account are no longer filtered by the Allow Chat Controls and Allow Chat Params permissions, which now only hide the Chat Controls and Advanced Parameters panels. [#32860](https://github.com/open-webui/open-webui/pull/32860), [Commit](https://github.com/open-webui/open-webui/commit/db5b8749448c5cb2a1e42f1bd0c01213bc2c31d1), [#32626](https://github.com/open-webui/open-webui/issues/32626) +- 🚦 **Stricter MCP timeouts and redirects.** "MCP_INITIALIZE_TIMEOUT", 10 seconds by default, now also limits listing tools and reading resources on MCP servers, so slow servers may need a higher value, and MCP connections now only follow redirects that stay on the same address. [#33087](https://github.com/open-webui/open-webui/pull/33087), [Commit](https://github.com/open-webui/open-webui/commit/8151826483163877cec837b35ba7c1affdd1fb5a) +- ⌨️ **Shift quick actions removed.** Holding Shift over a sidebar chat no longer shows the quick Archive and Delete buttons, since Shift-click now selects chats, which are then archived or deleted together, and deleting asks for confirmation first. [Commit](https://github.com/open-webui/open-webui/commit/acc231774d00d8856feefd2e7a3ef4b503a8224e), [#32543](https://github.com/open-webui/open-webui/issues/32543) +- ✉️ **No emails on hover.** Hovering an owner in the workspace lists and admin Functions, or a person in access settings, no longer shows their email address, since the profile card that replaces the tooltip does not include it. [Commit](https://github.com/open-webui/open-webui/commit/02974f41e9b7c9d34f2cd7c664c2a04fd6ac12ae), [Commit](https://github.com/open-webui/open-webui/commit/72cc79b8f336a753b6e75e0f3415630601b14759), [Commit](https://github.com/open-webui/open-webui/commit/ba34b1c47c1d054d2f9c498d0aed862ec06da41a) + ## [0.12.3] - 2026-10-16 ### Added diff --git a/Dockerfile b/Dockerfile index 4bf9cfc57c..c7fb201b4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,7 @@ RUN chown -R $UID:$GID /app/backend && \ chmod -R g=u /app/backend/open_webui/static ######## WebUI backend ######## -FROM python:3.11-slim-bookworm AS base +FROM python:3.12-slim-trixie AS base # Use args ARG USE_CUDA diff --git a/README.md b/README.md index ab0fdd1345..17b816309d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Open WebUI is **a home for AI**, a self-hosted AI platform that's **[extensible] Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/) -![Open WebUI Demo](./demo.png) +![Open WebUI Demo](https://community.s3.openwebui.com/uploads/users/6d849fdd-b93f-411e-a611-eb25c73f1154/2026/10/25aebd3e-1d1c-4177-98b3-317ce60f75e9.gif) > [!TIP] > **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)** @@ -109,7 +109,7 @@ We are incredibly grateful for the generous support of our sponsors. Their contr ### Installation via Python pip 🐍 -Open WebUI can be installed using pip, the Python package installer. Before proceeding, ensure you're using **Python 3.11** to avoid compatibility issues. +Open WebUI can be installed using pip, the Python package installer. Before proceeding, ensure you're using **Python 3.12** to avoid compatibility issues. 1. **Install Open WebUI**: Open your terminal and run the following command to install Open WebUI: diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index 0611bda1c6..fc1804f96f 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -57,8 +57,8 @@ def serve( os.environ['LD_LIBRARY_PATH'] = ':'.join( LD_LIBRARY_PATH + [ - '/usr/local/lib/python3.11/site-packages/torch/lib', - '/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib', + '/usr/local/lib/python3.12/site-packages/torch/lib', + '/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib', ] ) try: diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 832f1b1ce7..48d4546b0a 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -14,7 +14,7 @@ from urllib.parse import urlparse import redis import requests from authlib.integrations.starlette_client import OAuth -from pydantic import BaseModel +from pydantic import BaseModel, StrictBool, TypeAdapter from open_webui.env import ( USE_SLIM, @@ -1745,6 +1745,12 @@ except Exception as e: DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {} +try: + UI_VISIBILITY = TypeAdapter(dict[str, StrictBool]).validate_json(os.getenv('UI_VISIBILITY', '{}')) +except Exception as e: + log.exception(f'Error loading UI_VISIBILITY: {e}') + UI_VISIBILITY = {} + DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending') DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '') @@ -1786,6 +1792,10 @@ USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT = ( os.getenv('USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT', 'False').lower() == 'true' ) +USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN = ( + os.getenv('USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN', 'False').lower() == 'true' +) + USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT = ( os.getenv('USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT', 'False').lower() == 'true' ) @@ -1978,6 +1988,7 @@ DEFAULT_USER_PERMISSIONS = { 'skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS, 'models_import': USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT, 'models_export': USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT, + 'models_hidden': USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN, 'prompts_import': USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT, 'prompts_export': USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT, 'tools_import': USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT, @@ -2058,6 +2069,8 @@ ENABLE_FOLDERS = os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true' FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '') +ENABLE_SKILL_LAZY_LOADING = os.getenv('ENABLE_SKILL_LAZY_LOADING', 'False').lower() == 'true' + SKILL_CONTENT_MAX_CHARS = int(os.environ['SKILL_CONTENT_MAX_CHARS']) if os.getenv('SKILL_CONTENT_MAX_CHARS') else None if SKILL_CONTENT_MAX_CHARS is not None and SKILL_CONTENT_MAX_CHARS <= 0: raise ValueError('SKILL_CONTENT_MAX_CHARS must be a positive integer or unset') @@ -2875,6 +2888,8 @@ LDAP_SERVER_PORT = int(os.getenv('LDAP_SERVER_PORT', '389')) LDAP_ATTRIBUTE_FOR_MAIL = os.getenv('LDAP_ATTRIBUTE_FOR_MAIL', 'mail') +LDAP_DEFAULT_EMAIL_DOMAIN = os.getenv('LDAP_DEFAULT_EMAIL_DOMAIN', '') + LDAP_ATTRIBUTE_FOR_USERNAME = os.getenv('LDAP_ATTRIBUTE_FOR_USERNAME', 'uid') LDAP_APP_DN = os.getenv('LDAP_APP_DN', '') @@ -2900,6 +2915,7 @@ ENABLE_LDAP_GROUP_CREATION = os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lo LDAP_ATTRIBUTE_FOR_GROUPS = os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf') DEFAULT_CONFIG = { + 'skills.lazy_loading.enable': ENABLE_SKILL_LAZY_LOADING, 'skills.content_max_chars': SKILL_CONTENT_MAX_CHARS, 'skills.preview_max_chars': SKILL_PREVIEW_MAX_CHARS, 'direct.enable': ENABLE_DIRECT_CONNECTIONS, @@ -3181,6 +3197,7 @@ DEFAULT_CONFIG = { 'ui.default_models': DEFAULT_MODELS, 'ui.default_pinned_models': DEFAULT_PINNED_MODELS, 'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS, + 'ui.visibility': UI_VISIBILITY, 'ui.i18n': {}, 'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS, 'ui.prompt_suggestions_i18n': DEFAULT_PROMPT_SUGGESTIONS_I18N, @@ -3316,6 +3333,7 @@ DEFAULT_CONFIG = { 'ldap.server.host': LDAP_SERVER_HOST, 'ldap.server.port': LDAP_SERVER_PORT, 'ldap.server.attribute_for_mail': LDAP_ATTRIBUTE_FOR_MAIL, + 'ldap.server.default_email_domain': LDAP_DEFAULT_EMAIL_DOMAIN, 'ldap.server.attribute_for_username': LDAP_ATTRIBUTE_FOR_USERNAME, 'ldap.server.app_dn': LDAP_APP_DN, 'ldap.server.app_password': LDAP_APP_PASSWORD, diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 1f3a719bcf..fb23849f7d 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -699,9 +699,11 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT -# Timeout (in seconds) for the MCP session.initialize() handshake. -# The handshake performs a list-tools round-trip and can take tens of -# seconds on cold-start servers or servers exposing many tools. +# Timeout (in seconds) for each MCP protocol request other than a tool call: +# the legacy initialize handshake, tool listing and resource reads. Raise it +# for cold-start servers or servers exposing many tools. The SDK bounds its +# server/discover probe separately (10s), and tool calls use +# AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER. MCP_INITIALIZE_TIMEOUT = os.getenv('MCP_INITIALIZE_TIMEOUT', '10') try: MCP_INITIALIZE_TIMEOUT = int(MCP_INITIALIZE_TIMEOUT) @@ -857,6 +859,16 @@ MINERU_MAX_MARKDOWN_BYTES = ( int(os.getenv('MINERU_MAX_MARKDOWN_BYTES')) if os.getenv('MINERU_MAX_MARKDOWN_BYTES') else None ) +rapidocr_params = os.getenv('RAPIDOCR_PARAMS', '{"EngineConfig.onnxruntime.intra_op_num_threads": 1}') +try: + rapidocr_params = json.loads(rapidocr_params) +except json.JSONDecodeError: + rapidocr_params = {} +if not isinstance(rapidocr_params, dict): + rapidocr_params = {} + +RAPIDOCR_PARAMS = rapidocr_params + # When enabled, skips pydub-based preprocessing (format conversion, compression, # and chunked splitting) before sending files to processing engines. Useful when # the upstream provider handles these steps or when ffmpeg is unavailable. diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 0bdd0f5162..825e2a2304 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -1128,10 +1128,6 @@ class SocketSessionEventSink: EVENTS.FOLDER_UPDATED.name, EVENTS.FOLDER_PARENT_UPDATED.name, EVENTS.FOLDER_DELETED.name, - EVENTS.GROUP_MEMBER_REMOVED.name, - EVENTS.GROUP_MEMBER_ADDED.name, - EVENTS.GROUP_UPDATED.name, - EVENTS.GROUP_DELETED.name, EVENTS.CHAT_DELETED_ALL.name, }: await refresh_chat_access() @@ -1140,8 +1136,11 @@ class SocketSessionEventSink: EVENTS.CHAT_UNSHARED.name, EVENTS.CHAT_FOLDER_UPDATED.name, EVENTS.CHAT_DELETED.name, + EVENTS.CHAT_ARCHIVED.name, }: - await refresh_chat_access((event.subject or {}).get('id')) + subject = event.subject or {} + # User-scoped events (archive all, unshare all) recheck every chat. + await refresh_chat_access(subject.get('id') if subject.get('type') == 'chat' else None) if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}: return diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index ed691663fb..7433f1cd5f 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import os import re @@ -476,6 +477,12 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: @event.listens_for(async_engine.sync_engine, 'connect') def _set_sqlite_pragmas(dbapi_connection, connection_record): _apply_sqlite_pragmas(dbapi_connection) + + @event.listens_for(async_engine.sync_engine, 'handle_error') + def _keep_connection_on_cancel(context): + # invalidating here would close the connection inside the cancelled task, and that close is lost + if isinstance(context.original_exception, asyncio.CancelledError): + context.is_disconnect = False else: if isinstance(DATABASE_POOL_SIZE, int): if DATABASE_POOL_SIZE > 0: @@ -514,20 +521,14 @@ AsyncSessionLocal = async_sessionmaker( async def get_async_session(): """Async session generator for FastAPI Depends().""" async with AsyncSessionLocal() as db: - try: - yield db - finally: - await db.close() + yield db @asynccontextmanager async def get_async_db(): """Async context manager for use outside of FastAPI dependency injection.""" async with AsyncSessionLocal() as db: - try: - yield db - finally: - await db.close() + yield db @asynccontextmanager diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index ff48b1354f..864d8d189c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1217,12 +1217,6 @@ async def chat_completion( if not isinstance(model_controls, dict): raise HTTPException(400, 'Model control options must be keyed by model.') model_controls = {} if form_data.get('automation_id') else model_controls - if any(model_controls.values()) and user.role != 'admin': - permissions = await Config.get('user.permissions') - for permission in ('chat.controls', 'chat.params'): - if not await has_permission(user.id, permission, permissions): - model_controls = {} - break if missing_base_model and model_controls.get(model_id): raise HTTPException(400, 'Model control options cannot be applied to the fallback model.') if model_info_params or request_params: @@ -1304,6 +1298,8 @@ async def chat_completion( ) ): tool_servers = None + if tool_servers and not await Config.get('direct.integrations.enable', False): + tool_servers = None automation_id = form_data.pop('automation_id', None) tool_approval_mode = ( @@ -1329,6 +1325,7 @@ async def chat_completion( 'user_message': user_message, 'user_message_id': user_message.get('id') if user_message else None, 'assistant_message_id': form_data.pop('assistant_message_id', None), + 'regeneration_message_id': form_data.pop('regeneration_message_id', None), 'session_id': form_data.pop('session_id', None), 'automation_id': automation_id, 'folder_id': form_data.pop('folder_id', None), @@ -2337,6 +2334,7 @@ async def get_app_config(request: Request): 'ui.enable_password_change_form', 'direct.enable', 'direct.integrations.enable', + 'terminal_server.connections', 'folders.enable', 'folders.max_file_count', 'channels.enable', @@ -2354,6 +2352,9 @@ async def get_app_config(request: Request): 'code_interpreter.enable', 'image_generation.enable', 'task.autocomplete.enable', + 'task.title.enable', + 'task.follow_up.enable', + 'task.tags.enable', 'ui.enable_community_sharing', 'ui.enable_message_rating', 'ui.enable_user_webhooks', @@ -2361,9 +2362,11 @@ async def get_app_config(request: Request): 'google_drive.enable', 'onedrive.enable', 'memories.enable', + 'subagents.enable', 'ui.default_models', 'ui.default_pinned_models', 'ui.default_interface_settings', + 'ui.visibility', 'ui.i18n', 'ui.prompt_suggestions', 'ui.prompt_suggestions_i18n', @@ -2434,6 +2437,11 @@ async def get_app_config(request: Request): 'enable_tools': ENABLE_TOOLS, 'enable_functions': ENABLE_FUNCTIONS, 'enable_tool_servers': ENABLE_TOOL_SERVERS, + 'enable_terminal_servers': ENABLE_TOOL_SERVERS + and any( + connection.get('enabled', True) + for connection in config.get('terminal_server.connections') or [] + ), 'enable_folders': config.get('folders.enable'), 'folder_max_file_count': config.get('folders.max_file_count'), 'enable_channels': config.get('channels.enable'), @@ -2451,6 +2459,9 @@ async def get_app_config(request: Request): 'enable_code_interpreter': config.get('code_interpreter.enable'), 'enable_image_generation': config.get('image_generation.enable'), 'enable_autocomplete_generation': config.get('task.autocomplete.enable'), + 'enable_title_generation': config.get('task.title.enable'), + 'enable_follow_up_generation': config.get('task.follow_up.enable'), + 'enable_tags_generation': config.get('task.tags.enable'), 'enable_community_sharing': config.get('ui.enable_community_sharing'), 'enable_message_rating': config.get('ui.enable_message_rating'), 'enable_user_webhooks': config.get('ui.enable_user_webhooks'), @@ -2461,6 +2472,7 @@ async def get_app_config(request: Request): 'enable_google_drive_integration': config.get('google_drive.enable'), 'enable_onedrive_integration': config.get('onedrive.enable'), 'enable_memories': config.get('memories.enable'), + 'enable_subagents': config.get('subagents.enable'), **( { 'enable_onedrive_personal': ENABLE_ONEDRIVE_PERSONAL, @@ -2521,6 +2533,7 @@ async def get_app_config(request: Request): }, 'ui': { 'default_interface_settings': config.get('ui.default_interface_settings'), + 'visibility': config.get('ui.visibility'), 'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'), 'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'), 'response_watermark': config.get('ui.watermark'), @@ -3117,6 +3130,8 @@ async def check_db_health(): mimetypes.add_type('text/javascript', '.js') mimetypes.add_type('text/javascript', '.mjs') mimetypes.add_type('application/wasm', '.wasm') +# Python before 3.13 only knows .webp from /etc/mime.types, which the Docker image does not ship +mimetypes.add_type('image/webp', '.webp') # Serve build-time static assets (CSS, JS, images, favicon, etc.) app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 71b400996e..a7489e925f 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -3,8 +3,8 @@ import time from typing import Optional from uuid import uuid4 -from open_webui.internal.db import Base, get_async_db_context from open_webui.constants import ERROR_MESSAGES +from open_webui.internal.db import Base, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse @@ -176,7 +176,6 @@ class CalendarUpdateForm(BaseModel): color: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None - access_grants: Optional[list[dict]] = None async def validate_calendar_rrule(value: Optional[str]) -> None: @@ -362,8 +361,6 @@ class CalendarTable: cal.data = {**(cal.data or {}), **update_data['data']} if 'meta' in update_data: cal.meta = {**(cal.meta or {}), **update_data['meta']} - if 'access_grants' in update_data: - await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db) cal.updated_at = int(time.time_ns()) await db.commit() diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 83e3f85bd7..1876f41c03 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -16,8 +16,8 @@ from sqlalchemy import ( JSON, BigInteger, Boolean, - Column, CheckConstraint, + Column, ForeignKey, String, Text, @@ -243,12 +243,12 @@ class ChannelForm(BaseModel): is_private: Optional[bool] = None data: Optional[dict] = None meta: Optional[dict] = None - access_grants: Optional[list[dict]] = None group_ids: Optional[list[str]] = None user_ids: Optional[list[str]] = None class CreateChannelForm(ChannelForm): + access_grants: Optional[list[dict]] = None type: Optional[str] = None @@ -896,8 +896,6 @@ class ChannelTable: channel.data = form_data.data channel.meta = form_data.meta - if form_data.access_grants is not None: - await AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db) channel.updated_at = int(time.time_ns()) await db.commit() diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index de5366ec52..84c522a725 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -1,8 +1,10 @@ from contextlib import nullcontext +import os import time import uuid from collections import Counter from datetime import datetime, timedelta +from pathlib import Path from typing import Any, Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -17,12 +19,15 @@ from sqlalchemy import ( Boolean, Column, ForeignKey, + Float, Index, Integer, Text, cast, + case, delete, func, + literal, select, ) from sqlalchemy.ext.asyncio import AsyncSession @@ -88,6 +93,36 @@ def _token_columns(dialect: str): ) +def _daily_usage_bucket(dialect: str, bucket_offset: int): + """Match Python's server-local day and legacy timestamp normalization in SQL.""" + if dialect not in {'sqlite', 'postgresql'}: + return None + if dialect == 'postgresql': + # The database server/session can use a different timezone from the application. + zone = os.environ.get('TZ') + if zone is None: + localtime = str(Path('/etc/localtime').resolve()) + if '/zoneinfo/' in localtime: + zone = localtime.split('/zoneinfo/', 1)[1] + elif time.timezone == 0 and not time.daylight: + zone = 'UTC' + try: + ZoneInfo(zone or '') + except (ValueError, ZoneInfoNotFoundError): + # Retain Python bucketing when the OS timezone has no portable IANA name. + return None + + now = time.time() + seconds = case( + (ChatMessage.created_at > 10_000_000_000, cast(ChatMessage.created_at, Float) / 1000), + else_=ChatMessage.created_at, + ) + timestamp = case((seconds.between(1577836800, now + 86400), seconds), else_=now) + bucket_offset + if dialect == 'sqlite': + return func.strftime('%Y-%m-%d', timestamp, 'unixepoch', 'localtime') + return func.to_char(func.timezone(zone, func.to_timestamp(timestamp)), 'YYYY-MM-DD') + + def _extract_tool_names(value: Any) -> list[str]: names: list[str] = [] @@ -519,6 +554,37 @@ class ChatMessageTable: return True # Analytics methods + async def get_analytics_summary( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + group_id: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> dict[str, int]: + """Return dashboard totals without materializing every model, user, and chat group.""" + async with get_async_db_context(db) as db: + from open_webui.models.groups import group_user_memberships + + stmt = select( + func.count(ChatMessage.model_id).label('total_messages'), + func.count(distinct(ChatMessage.chat_id)).label('total_chats'), + func.count(distinct(ChatMessage.model_id)).label('total_models'), + # GROUP BY user_id included the anonymous/null group in the old summary. + ( + func.count(distinct(ChatMessage.user_id)) + + case((func.count() > func.count(ChatMessage.user_id), 1), else_=0) + ).label('total_users'), + ).where(ChatMessage.role == 'assistant') + if start_date: + stmt = stmt.where(ChatMessage.created_at >= start_date) + if end_date: + stmt = stmt.where(ChatMessage.created_at <= end_date) + if group_id: + group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery() + stmt = stmt.where(ChatMessage.user_id.in_(group_users)) + + return dict((await db.execute(stmt)).mappings().one()) + async def get_message_count_by_model( self, start_date: Optional[int] = None, @@ -546,19 +612,20 @@ class ChatMessageTable: result = await db.execute(stmt) return {row.model_id: row.count for row in result.all()} - async def get_unique_counts_by_model( + async def get_model_analytics( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict]: - """Count distinct users and chats per model.""" + """Count messages, distinct users, and chats per model in one pass.""" async with get_async_db_context(db) as db: from open_webui.models.groups import group_user_memberships stmt = select( ChatMessage.model_id, + func.count(ChatMessage.id).label('count'), func.count(distinct(ChatMessage.user_id)).label('unique_users'), func.count(distinct(ChatMessage.chat_id)).label('unique_chats'), ).filter( @@ -578,6 +645,7 @@ class ChatMessageTable: result = await db.execute(stmt) return { row.model_id: { + 'count': row.count, 'unique_users': row.unique_users, 'unique_chats': row.unique_chats, } @@ -634,14 +702,14 @@ class ChatMessageTable: for row in result.all() } - async def get_token_usage_by_user( + async def get_user_analytics( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict]: - """Aggregate token usage by user using database-level aggregation.""" + """Aggregate all message counts and token usage per user in one pass.""" async with get_async_db_context(db) as db: from open_webui.models.groups import group_user_memberships @@ -650,6 +718,10 @@ class ChatMessageTable: input_tokens, output_tokens = _token_columns(dialect) + # The previous token query excluded anonymous users; keep those totals at zero. + input_tokens = case((ChatMessage.user_id.isnot(None), input_tokens), else_=0) + output_tokens = case((ChatMessage.user_id.isnot(None), output_tokens), else_=0) + stmt = select( ChatMessage.user_id, func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), @@ -657,8 +729,6 @@ class ChatMessageTable: func.count(ChatMessage.id).label('message_count'), ).filter( ChatMessage.role == 'assistant', - ChatMessage.user_id.isnot(None), - ChatMessage.usage.isnot(None), ) if start_date: @@ -961,20 +1031,43 @@ class ChatMessageTable: result = await db.execute(stmt) return {row.chat_id: row.count for row in result.all()} - async def get_daily_message_counts_by_model( + async def get_usage_over_time_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, + granularity: str = 'daily', + metric: str = 'messages', + bucket_offset: int = 0, ) -> dict[str, dict[str, int]]: - """Get message counts grouped by day and model.""" - async with get_async_db_context(db) as db: - from datetime import datetime, timedelta + """Get message counts or total tokens grouped by time bucket and model.""" + bucket_minutes = {'10m': 10, 'hourly': 60, '3h': 180, '6h': 360, 'daily': 1440}[granularity] + date_format = '%Y-%m-%d' if granularity == 'daily' else '%Y-%m-%d %H:%M' + step = timedelta(minutes=bucket_minutes) + def bucket_start(timestamp): + # Shift comparison events before bucketing so partial intervals align exactly. + date = datetime.fromtimestamp(_normalize_timestamp(timestamp) + bucket_offset) + minutes = date.hour * 60 + date.minute + minutes = minutes // bucket_minutes * bucket_minutes + return date.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0) + + async with get_async_db_context(db) as db: from open_webui.models.groups import group_user_memberships - stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( + bind = await db.connection() + day = _daily_usage_bucket(bind.dialect.name, bucket_offset) if granularity == 'daily' else None + value = literal(1) + if metric == 'tokens': + input_tokens, output_tokens = _token_columns(bind.dialect.name) + value = func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0) + + stmt = select( + day if day is not None else ChatMessage.created_at, + ChatMessage.model_id, + func.sum(value) if day is not None else value, + ).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ) @@ -987,79 +1080,23 @@ class ChatMessageTable: group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) + if day is not None: + stmt = stmt.group_by(day, ChatMessage.model_id) result = await db.execute(stmt) - results = result.all() + counts: dict[str, dict[str, int]] = {} + for timestamp, model_id, value in result.all(): + date_str = timestamp if day is not None else bucket_start(timestamp).strftime(date_format) + bucket = counts.setdefault(date_str, {}) + bucket[model_id] = bucket.get(model_id, 0) + int(value) - # Group by date -> model -> count - daily_counts: dict[str, dict[str, int]] = {} - for timestamp, model_id in results: - date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d') - if date_str not in daily_counts: - daily_counts[date_str] = {} - daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 - - # Fill in missing days if start_date and end_date: - current = datetime.fromtimestamp(_normalize_timestamp(start_date)) - end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) + current = bucket_start(start_date) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date) + bucket_offset) while current <= end_dt: - date_str = current.strftime('%Y-%m-%d') - if date_str not in daily_counts: - daily_counts[date_str] = {} - current += timedelta(days=1) + counts.setdefault(current.strftime(date_format), {}) + current += step - return daily_counts - - async def get_hourly_message_counts_by_model( - self, - start_date: Optional[int] = None, - end_date: Optional[int] = None, - group_id: Optional[str] = None, - db: Optional[AsyncSession] = None, - ) -> dict[str, dict[str, int]]: - """Get message counts grouped by hour and model.""" - async with get_async_db_context(db) as db: - from datetime import datetime, timedelta - - from open_webui.models.groups import group_user_memberships - - stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( - ChatMessage.role == 'assistant', - ChatMessage.model_id.isnot(None), - ) - - if start_date: - stmt = stmt.filter(ChatMessage.created_at >= start_date) - if end_date: - stmt = stmt.filter(ChatMessage.created_at <= end_date) - if group_id: - group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery() - stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - - result = await db.execute(stmt) - results = result.all() - - # Group by hour -> model -> count - hourly_counts: dict[str, dict[str, int]] = {} - for timestamp, model_id in results: - hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00') - if hour_str not in hourly_counts: - hourly_counts[hour_str] = {} - hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 - - # Fill in missing hours - if start_date and end_date: - current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace( - minute=0, second=0, microsecond=0 - ) - end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) - while current <= end_dt: - hour_str = current.strftime('%Y-%m-%d %H:00') - if hour_str not in hourly_counts: - hourly_counts[hour_str] = {} - current += timedelta(hours=1) - - return hourly_counts + return counts ChatMessages = ChatMessageTable() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 67e43bb639..901e115961 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -421,7 +421,7 @@ class ChatTable: { ChatMessages.DB_TO_JSON_KEY_MAP.get(column.key, column.key): getattr(row, column.key) for column in ChatMessage.__table__.columns - if column.key not in ChatMessages.EXCLUDED_COLUMNS + if column.key not in ChatMessages.EXCLUDED_COLUMNS and getattr(row, column.key) is not None } ) message['id'] = message_id @@ -852,6 +852,18 @@ class ChatTable: except Exception: return + async def update_chat_params_by_id(self, id: str, params: dict) -> ChatModel | None: + async with self._chat_transaction(id) as (_, chat): + if chat is None: + return None + + stored = chat.chat or {} + chat.chat = { + **stored, + 'params': self._clean_null_bytes({**(stored.get('params') or {}), **params}), + } + return ChatModel.model_validate(chat) + async def update_chat_variables_by_id( self, id: str, @@ -1565,7 +1577,7 @@ class ChatTable: async def archive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool: try: async with get_async_db_context(db) as session: - await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=True)) + await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=True, folder_id=None)) await session.commit() return True except Exception: @@ -1781,6 +1793,13 @@ class ChatTable: result = await session.execute(stmt) return [meta for meta in result.scalars().all() if isinstance(meta, dict)] + async def get_owner_ids_by_chat_ids(self, chat_ids: list[str], db: AsyncSession | None = None) -> dict[str, str]: + if not chat_ids: + return {} + async with get_async_db_context(db) as session: + result = await session.execute(select(Chat.id, Chat.user_id).filter(Chat.id.in_(chat_ids))) + return dict(result.all()) + async def get_chats_by_model_id( self, model_id: str, @@ -2519,7 +2538,8 @@ class ChatTable: chat = await session.get(Chat, id) chat.folder_id = folder_id chat.updated_at = int(time.time()) - chat.last_read_at = int(time.time()) + if chat.user_id == user_id: + chat.last_read_at = int(time.time()) chat.pinned = False if folder_id is not None: # Folder listings only show unarchived chats, so moving an archived diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 6597928ce3..7d477f1836 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -253,13 +253,12 @@ async def refresh_group_sessions(user_ids): return # Import lazily to avoid models/socket import cycles. A committed write must not # be reported as failed just because a client has already disconnected. - from open_webui.socket.main import disconnect_user_sessions + from open_webui.socket.main import disconnect_sessions_for_users - for user_id in set(user_ids): - try: - await disconnect_user_sessions(user_id, refresh_access=True) - except Exception: - log.exception('Unable to refresh group access for user %s', user_id) + try: + await disconnect_sessions_for_users(user_ids, refresh_access=True) + except Exception: + log.exception('Unable to refresh group access for %s user(s)', len(user_ids)) async def validate_parent(db, group_id, parent_id): diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index d2b8c58b8d..e9b24ac9c2 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -792,8 +792,6 @@ class KnowledgeTable: ) ) await db.commit() - if form_data.access_grants is not None: - await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) return await self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index c5fc828e8b..91c0e85461 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -7,10 +7,10 @@ from copy import deepcopy from typing import Annotated, Any, Literal from fastapi import HTTPException -from open_webui.models.model_history import ModelHistory, ModelHistories, model_snapshot from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants from open_webui.models.groups import Groups +from open_webui.models.model_history import ModelHistories, ModelHistory, model_snapshot from open_webui.models.users import User, UserModel, UserResponse, Users from open_webui.utils.misc import json_text_variants from open_webui.utils.validate import validate_image_url @@ -280,6 +280,7 @@ class ModelsTable: **data, user_id=user_id, version_id=entry.id, created_at=int(time.time()), updated_at=int(time.time()) ) session.add_all([current, entry]) + await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants) else: if production_version_id is not None: # Serialize with history deletion before reading the selected snapshot. @@ -322,8 +323,6 @@ class ModelsTable: ) if result.rowcount != 1: raise HTTPException(409, {'code': 'version_conflict'}) - if form.access_grants is not None or current in session.new: - await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants) return current async def _written_model(self, session, model): diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 5ee99f2e8c..40ca4ef95e 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -109,7 +109,6 @@ class NoteUpdateForm(BaseModel): title: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None - access_grants: Optional[list[dict]] = None @field_validator('data', mode='before') @classmethod @@ -376,12 +375,9 @@ class NoteTable: if 'meta' in form_data: note.meta = {**(note.meta or {}), **(form_data['meta'] or {})} - if not db.is_modified(note) and 'access_grants' not in form_data: + if not db.is_modified(note): return await self._to_note_model(note, db=db) - if 'access_grants' in form_data: - await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db) - note.updated_at = int(time.time_ns()) await db.commit() diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index bfdefccfb6..2c50b74d7e 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -434,11 +434,7 @@ class PromptsTable: current_access_grants = await self._get_access_grants(prompt.id, db=session) # Check if content changed to decide on history creation - content_changed = ( - prompt.name != form_data.name - or prompt.content != form_data.content - or form_data.access_grants is not None - ) + content_changed = prompt.name != form_data.name or prompt.content != form_data.content # Update prompt fields prompt.name = form_data.name @@ -446,10 +442,6 @@ class PromptsTable: prompt.data = form_data.data or prompt.data prompt.meta = form_data.meta or prompt.meta prompt.updated_at = int(time.time()) - if form_data.access_grants is not None: - await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session) - current_access_grants = await self._get_access_grants(prompt.id, db=session) - await session.commit() # Create history entry only if content changed @@ -504,7 +496,6 @@ class PromptsTable: prompt.name != form_data.name or prompt.command != form_data.command or prompt.content != form_data.content - or form_data.access_grants is not None or (form_data.tags is not None and prompt.tags != form_data.tags) ) @@ -520,10 +511,6 @@ class PromptsTable: if form_data.tags is not None: prompt.tags = form_data.tags - if form_data.access_grants is not None: - await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session) - current_access_grants = await self._get_access_grants(prompt.id, db=session) - prompt.updated_at = int(time.time()) await session.commit() diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index c568cd0f21..ffe58336e2 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -433,8 +433,6 @@ class SkillsTable: ) if result.rowcount != 1: raise HTTPException(409, {'code': 'version_conflict'}) - if updated.get('access_grants') is not None: - await AccessGrants.replace_access_grants(session, 'skill', id, updated['access_grants']) await session.commit() await session.refresh(skill) grants = ( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index be99c7550e..73ed8f0991 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging import time +from fastapi import HTTPException + # local imports from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants @@ -12,7 +14,6 @@ from open_webui.models.groups import Groups from open_webui.models.tool_history import ToolHistories, ToolHistory, tool_snapshot from open_webui.models.users import UserResponse, Users from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves -from fastapi import HTTPException from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import BigInteger, Column, String, Text, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -375,10 +376,8 @@ class ToolsTable: if not allow_code_changes and updated.get('content', tool.content) != tool.content: raise HTTPException(401, 'You do not have permission to change executable Tool code') updated = dict(updated) - grants = updated.pop('access_grants', None) + updated.pop('access_grants', None) await self._write_tool(session, tool, updated, user_id, version_id, module) - if grants is not None: - await AccessGrants.replace_access_grants(session, 'tool', id, grants) await session.flush() grants = ( (await session.execute(select(AccessGrant).filter_by(resource_type='tool', resource_id=id))) diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py index 0a3017cbd1..7a0c3af9eb 100644 --- a/backend/open_webui/retrieval/loaders/mineru.py +++ b/backend/open_webui/retrieval/loaders/mineru.py @@ -273,8 +273,12 @@ class MinerULoader: """ Send one request to the MinerU V1 API and raise an HTTPException on failure. """ + # A 4.x server started with --api-key rejects every V1 request without it + headers = {'Authorization': f'Bearer {self.api_key}'} if self.api_key else None try: - response = requests.request(method, f'{self.api_url}{path}', timeout=self.timeout, **kwargs) + response = requests.request( + method, f'{self.api_url}{path}', headers=headers, timeout=self.timeout, **kwargs + ) response.raise_for_status() except requests.Timeout: raise HTTPException( diff --git a/backend/open_webui/retrieval/loaders/pdf.py b/backend/open_webui/retrieval/loaders/pdf.py index 38ecd3b1a9..2769603ff3 100644 --- a/backend/open_webui/retrieval/loaders/pdf.py +++ b/backend/open_webui/retrieval/loaders/pdf.py @@ -6,6 +6,8 @@ from pathlib import Path from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document +from open_webui.env import RAPIDOCR_PARAMS + log = logging.getLogger(__name__) @@ -94,7 +96,7 @@ class PDFLoader(BaseLoader): if self.ocr is None: from rapidocr import RapidOCR - self.ocr = RapidOCR() + self.ocr = RapidOCR(params=RAPIDOCR_PARAMS) result = self.ocr(pixels) if result and result.txts: texts.append('\n'.join(result.txts).strip()) diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index 0a12f13b8b..b388974701 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -37,9 +37,9 @@ class ColBERT(BaseReranker): # This is a workaround for the issue with the docker container # where the torch extension is not loaded properly # and the following error is thrown: - # /root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/segmented_maxsim_cpp.so: cannot open shared object file: No such file or directory + # /root/.cache/torch_extensions/py312_cpu/segmented_maxsim_cpp/segmented_maxsim_cpp.so: cannot open shared object file: No such file or directory - lock_file = '/root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/lock' + lock_file = '/root/.cache/torch_extensions/py312_cpu/segmented_maxsim_cpp/lock' if os.path.exists(lock_file): os.remove(lock_file) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 7017da7c2e..56f4c8acf9 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1288,6 +1288,45 @@ def _is_safe_collection_name(name: str) -> bool: return isinstance(name, str) and bool(_SAFE_COLLECTION_NAME_RE.match(name)) +def resolve_collection_names(collection_name: str) -> set[str]: + """Names that may share a vector collection after backend normalization.""" + normalized_name = collection_name.lower().replace('_', '-').strip('-') + collection_names = {normalized_name} + # Weaviate prepends C to digit-leading names, so both spellings can reach the same collection. + if normalized_name[:1].isdigit(): + collection_names.add(f'c{normalized_name}') + elif normalized_name.startswith('c') and normalized_name[1:2].isdigit(): + collection_names.add(normalized_name[1:]) + return collection_names + + +async def has_access_to_collection(collection_name: str, user: UserModel, access_type: str) -> bool: + """Require access to every recognized collection, preserving the policy for unknown names.""" + has_authorized_collection = False + for resolved_name in resolve_collection_names(collection_name): + if resolved_name == 'knowledge-bases': + return False + if resolved_name.startswith('file-'): + has_access = await has_access_to_file( + file_id=resolved_name[len('file-') :], access_type=access_type, user=user + ) + elif resolved_name.startswith('user-memory-'): + has_access = resolved_name == f'user-memory-{user.id}' + elif resolved_name.startswith('web-search-'): + has_access = resolved_name.startswith(f'web-search-{user.id}-') + else: + has_access = await Knowledges.check_access_by_user_id(resolved_name, user.id, permission=access_type) + if not has_access and not await Knowledges.get_knowledge_by_id(resolved_name): + continue + + # A denied collection must win even when another name is authorized or unscoped names are enabled. + if not has_access: + return False + has_authorized_collection = True + + return has_authorized_collection or ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS + + async def filter_accessible_collections( collection_names: set[str], user: UserModel, @@ -1308,6 +1347,8 @@ async def filter_accessible_collections( ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS is True, the name is treated as a legacy/ephemeral collection and allowed. + + Access to every recognized collection is required; unknown names do not override a denial. """ # Applied before the admin bypass — malformed names should never reach the vector store. safe_names = {n for n in collection_names if _is_safe_collection_name(n)} @@ -1323,36 +1364,9 @@ async def filter_accessible_collections( return safe_names validated = set() - for name in safe_names: - # Some vector stores fold case and _/- together, so authorize a name as the collection it can resolve to. - canonical = name.lower().replace('_', '-') - if canonical == 'knowledge-bases': - # System meta-collection — never exposed to non-admins. - continue - elif canonical.startswith('file-'): - file_id = canonical[len('file-') :] - if await has_access_to_file(file_id=file_id, access_type=access_type, user=user): - validated.add(name) - elif canonical.startswith('user-memory-'): - if canonical == f'user-memory-{user.id}': - validated.add(name) - elif canonical.startswith('web-search-'): - # Ephemeral per-query collections, owner-bound: process_web_search mints - # them as web-search-{user.id}-, so only the creator may read/write. - if canonical.startswith(f'web-search-{user.id}-'): - validated.add(name) - else: - # May be a knowledge-base ID or a legacy/ephemeral collection. - # If it IS a KB, enforce access control. If no such KB - # exists, the behaviour depends on - # ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS: - # False (default) — deny (closes the unscoped namespace) - # True — allow (preserves legacy behaviour) - if await Knowledges.check_access_by_user_id(canonical, user.id, permission=access_type): - validated.add(name) - elif ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS and not await Knowledges.get_knowledge_by_id(canonical): - # Not a KB at all — legacy/ephemeral collection, allow - validated.add(name) + for collection_name in safe_names: + if await has_access_to_collection(collection_name, user, access_type): + validated.add(collection_name) return validated diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 7d57ab3262..e33e756dbb 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -1,9 +1,9 @@ import logging from collections import defaultdict from datetime import datetime, timedelta -from typing import Optional +from typing import Literal, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from open_webui.internal.db import get_async_session from open_webui.models.chat_messages import ChatMessageModel, ChatMessages from open_webui.models.chats import Chats @@ -64,20 +64,10 @@ async def get_model_analytics( db: AsyncSession = Depends(get_async_session), ): """Get message counts per model.""" - counts = await ChatMessages.get_message_count_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - unique_counts = await ChatMessages.get_unique_counts_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + counts = await ChatMessages.get_model_analytics(start_date=start_date, end_date=end_date, group_id=group_id, db=db) models = [ - ModelAnalyticsEntry( - model_id=model_id, - count=count, - unique_users=unique_counts.get(model_id, {}).get('unique_users', 0), - unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0), - ) - for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) + ModelAnalyticsEntry(model_id=model_id, **data) + for model_id, data in sorted(counts.items(), key=lambda x: -x[1]['count']) ] return ModelAnalyticsResponse(models=models) @@ -88,31 +78,33 @@ async def get_user_analytics( end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), group_id: Optional[str] = Query(None, description='Filter by user group ID'), limit: int = Query(50, description='Max users to return'), + metric: Literal['messages', 'tokens'] = Query('messages'), user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): """Get message counts and token usage per user with user info.""" - counts = await ChatMessages.get_message_count_by_user( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - token_usage = await ChatMessages.get_token_usage_by_user( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + usage = await ChatMessages.get_user_analytics(start_date=start_date, end_date=end_date, group_id=group_id, db=db) - # Get user info for top users - top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]] + # Rank by the selected metric before limiting the leaderboard. + top_user_ids = sorted( + usage, + key=lambda uid: ( + -usage[uid]['total_tokens'] if metric == 'tokens' else -usage[uid]['message_count'], + -usage[uid]['message_count'], + ), + )[:limit] user_info = {u.id: u for u in await Users.get_users_by_user_ids(top_user_ids, db=db)} users = [] for user_id in top_user_ids: u = user_info.get(user_id) - tokens = token_usage.get(user_id, {}) + tokens = usage[user_id] users.append( UserAnalyticsEntry( user_id=user_id, name=u.name if u else None, email=u.email if u else None, - count=counts[user_id], + count=tokens['message_count'], input_tokens=tokens.get('input_tokens', 0), output_tokens=tokens.get('output_tokens', 0), total_tokens=tokens.get('total_tokens', 0), @@ -169,27 +161,25 @@ async def get_summary( db: AsyncSession = Depends(get_async_session), ): """Get summary statistics for the dashboard.""" - model_counts = await ChatMessages.get_message_count_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - user_counts = await ChatMessages.get_message_count_by_user( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - chat_counts = await ChatMessages.get_message_count_by_chat( + summary = await ChatMessages.get_analytics_summary( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) - return SummaryResponse( - total_messages=sum(model_counts.values()), - total_chats=len(chat_counts), - total_models=len(model_counts), - total_users=len(user_counts), - ) + return SummaryResponse(**summary) + + +class ComparisonStatsEntry(BaseModel): + models: dict[str, int] + start: str + end: str class DailyStatsEntry(BaseModel): date: str models: dict[str, int] + start: Optional[str] = None + end: Optional[str] = None + previous: Optional[ComparisonStatsEntry] = None class DailyStatsResponse(BaseModel): @@ -201,22 +191,49 @@ async def get_daily_stats( start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), group_id: Optional[str] = Query(None, description='Filter by user group ID'), - granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"), + granularity: Literal['10m', 'hourly', '3h', '6h', 'daily'] = Query('daily'), + metric: Literal['messages', 'tokens'] = Query('messages'), + compare: bool = Query(False), user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): - """Get message counts grouped by model for time-series chart.""" - if granularity == 'hourly': - counts = await ChatMessages.get_hourly_message_counts_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - else: - counts = await ChatMessages.get_daily_message_counts_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - return DailyStatsResponse( - data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())] + """Get message counts or total tokens grouped by model for the time-series chart.""" + if compare and (start_date is None or end_date is None or start_date > end_date): + raise HTTPException(status_code=400, detail='Comparison requires a bounded date range') + counts = await ChatMessages.get_usage_over_time_by_model( + start_date=start_date, + end_date=end_date, + group_id=group_id, + db=db, + granularity=granularity, + metric=metric, ) + entries = [DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())] + if compare: + # API bounds are inclusive: equal windows must not share an endpoint. + duration = end_date - start_date + 1 + previous = await ChatMessages.get_usage_over_time_by_model( + start_date=start_date - duration, + end_date=start_date - 1, + group_id=group_id, + db=db, + granularity=granularity, + metric=metric, + bucket_offset=duration, + ) + step = timedelta(minutes={'10m': 10, 'hourly': 60, '3h': 180, '6h': 360, 'daily': 1440}[granularity]) + for entry in entries: + bucket = datetime.fromisoformat(entry.date) + start = max(start_date, int(bucket.timestamp())) + end = min(end_date + 1, int((bucket + step).timestamp())) + entry.start = datetime.fromtimestamp(start).isoformat() + entry.end = datetime.fromtimestamp(end).isoformat() + entry.previous = ComparisonStatsEntry( + models=previous.get(entry.date, {}), + start=datetime.fromtimestamp(start - duration).isoformat(), + end=datetime.fromtimestamp(end - duration).isoformat(), + ) + return DailyStatsResponse(data=entries) class TokenUsageEntry(BaseModel): diff --git a/backend/open_webui/routers/audio/realtime.py b/backend/open_webui/routers/audio/realtime.py index a6ee756679..c9c506eb34 100644 --- a/backend/open_webui/routers/audio/realtime.py +++ b/backend/open_webui/routers/audio/realtime.py @@ -9,13 +9,13 @@ from urllib.parse import urlencode, urlsplit, urlunsplit import aiohttp from fastapi import APIRouter, WebSocket, WebSocketDisconnect from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, DEFAULT_REALTIME_CALL_PROMPT_TEMPLATE -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, BYPASS_MODEL_ACCESS_CONTROL +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FORWARD_USER_INFO_HEADERS from open_webui.models.chats import Chats from open_webui.models.config import Config from open_webui.models.models import Models, ModelVoice from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_verified_user_by_token -from open_webui.utils.headers import get_microsoft_entra_id_access_token +from open_webui.utils.headers import get_microsoft_entra_id_access_token, include_user_info_headers from open_webui.utils.json_codec import JSONCodec from open_webui.utils.models import check_model_access, get_all_models from open_webui.utils.session_pool import get_session @@ -424,6 +424,8 @@ async def realtime_call(ws: WebSocket): async with asyncio.timeout(30): async with asyncio.timeout(15): ws_url, headers = await _get_realtime_connection(config) + if ENABLE_FORWARD_USER_INFO_HEADERS: + headers = include_user_info_headers(headers, user) session = await get_session() try: upstream = await session.ws_connect( @@ -547,6 +549,9 @@ async def realtime_call(ws: WebSocket): detail = ( str(exc) if type(exc) is ValueError + # A send can still fail on the provider connection that just closed. + else 'Voice provider connection closed' + if isinstance(exc, aiohttp.ClientConnectionResetError) else ('Voice connection timed out' if isinstance(exc, TimeoutError) else 'Voice connection failed') ) log.info('Bridge closed: user_id=%s error_type=%s', user.id if user else None, type(exc).__name__) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 701acfb360..d9b0d01d52 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -134,6 +134,7 @@ ADMIN_CONFIG_KEYS = { 'ENABLE_CALENDAR': 'calendar.enable', 'ENABLE_MEMORIES': 'memories.enable', 'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable', + 'ENABLE_SKILL_LAZY_LOADING': 'skills.lazy_loading.enable', 'SKILL_CONTENT_MAX_CHARS': 'skills.content_max_chars', 'SKILL_PREVIEW_MAX_CHARS': 'skills.preview_max_chars', 'ENABLE_NOTES': 'notes.enable', @@ -149,6 +150,7 @@ LDAP_SERVER_CONFIG_KEYS = { 'host': 'ldap.server.host', 'port': 'ldap.server.port', 'attribute_for_mail': 'ldap.server.attribute_for_mail', + 'default_email_domain': 'ldap.server.default_email_domain', 'attribute_for_username': 'ldap.server.attribute_for_username', 'app_dn': 'ldap.server.app_dn', 'app_dn_password': 'ldap.server.app_password', @@ -435,6 +437,7 @@ async def ldap_auth( LDAP_SERVER_HOST = await Config.get('ldap.server.host') LDAP_SERVER_PORT = await Config.get('ldap.server.port') LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail') + LDAP_DEFAULT_EMAIL_DOMAIN = await Config.get('ldap.server.default_email_domain') LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username') LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn') LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter') @@ -500,6 +503,10 @@ async def ldap_auth( entry = connection_app.entries[0] entry_username = entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'].value email = entry[f'{LDAP_ATTRIBUTE_FOR_MAIL}'].value # retrieve the Attribute value + if not email and LDAP_DEFAULT_EMAIL_DOMAIN: + default_email = f'{form_data.user}@{LDAP_DEFAULT_EMAIL_DOMAIN}' + if validate_email_format(default_email): + email = default_email username_list = [] # list of usernames from LDAP attribute if isinstance(entry_username, list): @@ -1187,6 +1194,7 @@ class AdminConfig(MfaConfigForm): ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool ENABLE_MEMORY_SYSTEM_CONTEXT: bool + ENABLE_SKILL_LAZY_LOADING: bool = False SKILL_CONTENT_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True) SKILL_PREVIEW_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True) ENABLE_NOTES: bool @@ -1237,6 +1245,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep updates.pop('apps.enable', None) if 'I18N' not in form_data.model_fields_set: updates.pop('ui.i18n', None) + if 'ENABLE_SKILL_LAZY_LOADING' not in form_data.model_fields_set: + updates.pop('skills.lazy_loading.enable', None) if 'SKILL_CONTENT_MAX_CHARS' not in form_data.model_fields_set: updates.pop('skills.content_max_chars', None) if 'SKILL_PREVIEW_MAX_CHARS' not in form_data.model_fields_set: @@ -1269,6 +1279,7 @@ class LdapServerConfig(BaseModel): host: str port: int | None = None attribute_for_mail: str = 'mail' + default_email_domain: str = '' attribute_for_username: str = 'uid' app_dn: str app_dn_password: str diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py index 8d2822f0a0..e4cf3ebb0d 100644 --- a/backend/open_webui/routers/calendar.py +++ b/backend/open_webui/routers/calendar.py @@ -23,10 +23,11 @@ from open_webui.models.calendar import ( from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.users import UserModel -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants from open_webui.utils.auth import get_verified_user from open_webui.utils.calendar import expand_recurring_event from open_webui.utils.recurrence import schedule_start_ns +from pydantic import BaseModel log = logging.getLogger(__name__) @@ -123,7 +124,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM user.id, user.role, form_data.access_grants, - 'sharing.public_calendars', + resource_type='calendar', ) calendar = await Calendars.insert_new_calendar(user.id, form_data) await publish_event( @@ -415,25 +416,7 @@ async def update_calendar( request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) ): await check_calendar_permission(request, user) - cal = await _check_calendar_access(calendar_id, user, 'write') - - # Only owner/admin can change access grants - if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': - raise HTTPException(status_code=403, detail='Only owner can manage sharing') - - # Strip public/user grants the requesting user is not permitted to assign - # (matches the channel/notes/models pattern). The owner-only check above - # only restricts WHO can set grants; this filter restricts WHICH grants - # they may set, so a non-admin owner cannot make their calendar - # publicly readable/writable without the corresponding sharing permission. - if form_data.access_grants is not None: - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_calendars', - ) + await _check_calendar_access(calendar_id, user, 'write') updated = await Calendars.update_calendar_by_id(calendar_id, form_data) if not updated: @@ -448,6 +431,30 @@ async def update_calendar( return updated +class CalendarAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/{calendar_id}/access/update', response_model=CalendarModel) +async def update_calendar_access_by_id( + request: Request, + calendar_id: str, + form_data: CalendarAccessGrantsForm, + user: UserModel = Depends(get_verified_user), +): + await check_calendar_permission(request, user) + cal = await _check_calendar_access(calendar_id, user, 'write') + if cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can manage sharing') + + grants = await validate_access_grants( + await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, resource_type='calendar' + ) + cal.access_grants = await AccessGrants.set_access_grants('calendar', calendar_id, grants) + await publish_event(request, EVENTS.CALENDAR_UPDATED, actor=user, subject_id=cal.id, data={'name': cal.name}) + return cal + + @router.delete('/{calendar_id}/delete') async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): await check_calendar_permission(request, user) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 805e7fa65b..f37668da90 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -42,15 +42,15 @@ from open_webui.models.users import ( Users, ) from open_webui.socket.main import ( - encryption, emit_to_users, + encryption, enter_room_for_users, get_user_ids_from_room, leave_room_for_users, sio, ) from open_webui.tasks import create_task -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants from open_webui.utils.apps import ( check_app_channel_access, create_app_message, @@ -334,7 +334,7 @@ async def create_new_channel( user.id, user.role, form_data.access_grants, - 'sharing.public_channels', + resource_type='channel', ) try: @@ -795,27 +795,9 @@ async def update_channel_by_id( if channel.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_channels', - ) - - previous_access_grants = channel.access_grants - try: await encryption.invalidate(id) channel = await Channels.update_channel_by_id(id, form_data, db=db) - # Group and DM channels use membership instead of access grants. - if form_data.access_grants is not None and channel.type not in ['group', 'dm']: - revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource( - 'channel', id, previous_access_grants, db=db - ) - revoked_user_ids.discard(channel.user_id) - await leave_room_for_users(f'channel:{id}', list(revoked_user_ids)) - await publish_event( request, EVENTS.CHANNEL_UPDATED, @@ -829,6 +811,48 @@ async def update_channel_by_id( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) +class ChannelAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/{id}/access/update', response_model=ChannelModel) +async def update_channel_access_by_id( + request: Request, + id: str, + form_data: ChannelAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) + if not channel: + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) + if channel.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + # Group and DM channels manage access through membership instead. + if channel.type in ['group', 'dm']: + raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + + grants = await validate_access_grants( + await Config.get('user.permissions'), + user.id, + user.role, + form_data.access_grants, + resource_type='channel', + db=db, + ) + previous_access_grants = channel.access_grants + await encryption.invalidate(id) + channel.access_grants = await AccessGrants.set_access_grants('channel', id, grants, db=db) + revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource('channel', id, previous_access_grants, db=db) + revoked_user_ids.discard(channel.user_id) + await leave_room_for_users(f'channel:{id}', list(revoked_user_ids)) + await publish_event( + request, EVENTS.CHANNEL_UPDATED, actor=user, subject_id=id, data={'name': channel.name, 'type': channel.type} + ) + return channel + + ############################ # DeleteChannelById ############################ @@ -1481,12 +1505,11 @@ async def post_new_message( except Exception as e: log.debug(e) - active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}') - # NOTE: We intentionally do NOT pass db to background_handler. # Background tasks should manage their own short-lived sessions to avoid # holding database connections during slow operations (e.g., LLM calls). async def background_handler(): + active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}') await create_task(None, app_channel_reply_handler(channel.id, message.id, user.id)) await model_response_handler(request, channel, message, user) await send_notification( @@ -2013,6 +2036,7 @@ async def delete_message_by_id( try: await Messages.delete_message_by_id(message_id, db=db) + await Messages.delete_replies_by_id(message_id, db=db) await sio.emit( 'events:channel', { diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 0ed3f5c738..013f3cddbe 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -37,10 +37,11 @@ from open_webui.models.config import Config from open_webui.models.folders import Folders from open_webui.models.shared_chats import ChatShareMode, ShareChatForm, SharedChatResponse, SharedChats from open_webui.models.tags import TagModel, Tags +from open_webui.models.users import UserModel from open_webui.socket.main import get_event_emitter from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission -from open_webui.utils.access_control.folders import has_folder_write_access +from open_webui.utils.access_control import has_permission, validate_access_grants +from open_webui.utils.access_control.folders import has_chat_move_access, has_folder_write_access from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user from open_webui.utils.chat_fork import build_fork_history from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage @@ -167,6 +168,16 @@ async def shared_chat_response(chat, user=None, db=None): return data +def set_message_authors(chat: dict, user: UserModel) -> None: + """Attribute client-supplied messages to the user.""" + messages = list((chat.get('history', {}).get('messages') or {}).values()) + messages.extend(chat.get('messages') or []) + for message in messages: + message['user_id'] = user.id + if message.get('role') == 'user': + message['user'] = {'id': user.id, 'name': user.name} + + async def add_active_state_to_chat_list( request: Request, chat_list: list[ChatTitleIdResponse] ) -> list[ChatTitleIdResponse]: @@ -832,6 +843,7 @@ async def create_new_chat( ) try: + set_message_authors(form_data.chat, user) chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) await publish_event( request, @@ -863,6 +875,8 @@ async def import_chats( await require_chat_import_permission(request, user, db) try: + for chat_form in form_data.chats: + set_message_authors(chat_form.chat, user) chats = await Chats.import_chats(user.id, form_data.chats, db=db) await Tags.ensure_tags_exist( list({tag_id for chat in chats for tag_id in (chat.meta or {}).get('tags', [])}), user.id, db=db @@ -1452,8 +1466,22 @@ async def update_chat_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id(id, db=db) + if chat and chat.user_id != user.id: + folder = await Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id, db=db) if chat.folder_id else None + if ( + not folder + or set(form_data.chat) != {'title'} + or form_data.variables is not None + or form_data.folder_id is not None + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + title = form_data.chat['title'] + if not isinstance(title, str) or not title.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Invalid title')) + form_data.chat['title'] = title.strip() if chat: + set_message_authors(form_data.chat, user) touch = 'history' in form_data.chat or 'messages' in form_data.chat chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch) if form_data.variables is not None: @@ -1474,6 +1502,8 @@ async def update_chat_by_id( subject_id=id, data={'title': chat.title}, ) + if chat.user_id != user.id: + return await shared_chat_response(chat, user, db=db) return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException( @@ -1482,6 +1512,39 @@ async def update_chat_by_id( ) +############################ +# UpdateChatParams +############################ + + +@router.patch('/{id}/params', response_model=dict) +async def update_chat_params( + request: Request, + id: str, + form_data: dict, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if not await Chats.get_chat_by_id_and_user_id(id, user.id, db=db): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chat = await Chats.update_chat_params_by_id(id, form_data) + if chat is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + await publish_event( + request, + EVENTS.CHAT_UPDATED, + actor=user, + subject_id=id, + data={'title': chat.title}, + ) + return chat.chat['params'] + + ############################ # UpdateChatMessageById ############################ @@ -2166,13 +2229,12 @@ async def update_shared_chat_access_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_chats', - 'sharing.open_chats', + resource_type='shared_chat', db=db, ) @@ -2258,7 +2320,10 @@ async def update_chat_folder_id_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id(id, db=db) + if chat and chat.user_id != user.id: + if not await has_chat_move_access(user.id, chat.folder_id, form_data.folder_id, db=db): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) if chat: # None is allowed: it moves the chat out of any folder. if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db): @@ -2275,6 +2340,8 @@ async def update_chat_folder_id_by_id( subject_id=id, data={'folder_id': form_data.folder_id}, ) + if chat.user_id != user.id: + return await shared_chat_response(chat, user, db=db) return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 9aca44f967..1a8585dd6e 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -31,7 +31,7 @@ from open_webui.utils.tools import ( set_terminal_servers, set_tool_servers, ) -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, StrictBool router = APIRouter() @@ -127,6 +127,24 @@ async def get_config_namespace(namespace: str, user=Depends(get_admin_user)): return await Config.get_namespace(namespace) +class InterfaceVisibilityForm(BaseModel): + visibility: dict[str, StrictBool] + + +@router.post('/interface/visibility', response_model=InterfaceVisibilityForm) +async def set_interface_visibility(request: Request, form_data: InterfaceVisibilityForm, user=Depends(get_admin_user)): + await Config.upsert({'ui.visibility': form_data.visibility}) + await publish_event( + request, + EVENTS.CONFIG_UPDATED, + actor=user, + subject_id='interface', + subject_type='config', + data={'keys': ['ui.visibility']}, + ) + return form_data + + ############################ # Connections Config ############################ diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 9d24ca9aac..1f2292e753 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -41,7 +41,7 @@ from open_webui.models.knowledge import Knowledges from open_webui.models.users import Users from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.routers.audio import transcribe -from open_webui.routers.retrieval import ProcessFileForm, process_file +from open_webui.routers.retrieval import ProcessFileForm, index_file, process_file from open_webui.storage.provider import Storage from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.misc import strict_match_mime_type @@ -136,6 +136,7 @@ async def process_uploaded_file( ): async def _process_handler(db_session): try: + await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session) content_type = file.content_type # Detect mis-labeled text files (e.g. .ts → video/mp2t) @@ -158,7 +159,7 @@ async def process_uploaded_file( file_metadata, user, ) - await process_file( + await index_file( request, ProcessFileForm(file_id=file_item.id, content=result.get('text', '')), user=user, @@ -178,11 +179,6 @@ async def process_uploaded_file( # extraction causes "Timeout reached while detecting # encoding" errors. log.info('Video file detected (%s), skipping text extraction', content_type) - await Files.update_file_data_by_id( - file_item.id, - {'status': 'completed'}, - db=db_session, - ) else: raise Exception(f'File type {content_type} is not supported for processing') @@ -191,7 +187,7 @@ async def process_uploaded_file( # configured content extraction engine. if not content_type: log.info('File type %s is not provided, but trying to process anyway', file.content_type) - await process_file( + await index_file( request, ProcessFileForm(file_id=file_item.id), user=user, @@ -233,10 +229,7 @@ async def process_uploaded_file( ) directory_id = None - # Keep the generic file status stream open until the - # KB-specific vector write and durable link both finish. - await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session) - await process_file( + await index_file( request, ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id), user=user, @@ -256,6 +249,9 @@ async def process_uploaded_file( log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}') raise + # Completion includes the durable knowledge link, when requested. + await Files.update_file_data_by_id(file_item.id, {'status': 'completed', 'error': None}, db=db_session) + except Exception as e: log.error(f'Error processing file: {file_item.id}') await Files.update_file_data_by_id( @@ -511,6 +507,15 @@ async def list_files( ############################ +async def _search_files(filename: str, content: bool, skip: int, limit: int, user_id: str | None, db: AsyncSession): + files = await Files.search_files(user_id=user_id, filename=filename, skip=skip, limit=limit, db=db) + if not content: + for file in files: + if file.data and 'content' in file.data: + del file.data['content'] + return files + + @router.get('/search', response_model=list[FileModelResponse]) async def search_files( filename: str = Query( @@ -523,36 +528,28 @@ async def search_files( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - """ - Search for files by filename with support for wildcard patterns. - Uses SQL-based filtering with pagination for better performance. - """ - # Determine user_id: null for admin with bypass (search all), user.id otherwise user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id - - # Use optimized database query with pagination - files = await Files.search_files( - user_id=user_id, - filename=filename, - skip=skip, - limit=limit, - db=db, - ) - + files = await _search_files(filename, content, skip, limit, user_id, db) if not files: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No files found matching the pattern.', ) - - if not content: - for file in files: - if file.data and 'content' in file.data: - del file.data['content'] - return files +@router.get('/user/search', response_model=list[FileModelResponse]) +async def search_user_files( + filename: str = Query('*'), + content: bool = Query(False), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=1000), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return await _search_files(filename, content, skip, limit, user.id, db) + + ############################ # Count Files ############################ @@ -567,6 +564,14 @@ async def count_files( return await Files.count_files_by_user_id(user_id=user_id, db=db) +@router.get('/user/count', response_model=int) +async def count_user_files( + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return await Files.count_files_by_user_id(user_id=user.id, db=db) + + ############################ # Delete All Files ############################ @@ -732,6 +737,9 @@ async def update_file_data_content_by_id( ) if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db): + if not form_data.content.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.EMPTY_CONTENT) + max_size = await Config.get('rag.file.max_size') if max_size and len(form_data.content.encode('utf-8')) > int(max_size) * 1024 * 1024: raise HTTPException( diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 47ee642fd5..d1dd1aa13d 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -12,10 +12,11 @@ from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.shared_chats import ChatShareMode +from open_webui.models.access_grants import AccessGrants +from open_webui.models.automations import Automations from open_webui.models.chat_messages import ChatMessages -from open_webui.models.config import Config from open_webui.models.chats import Chats +from open_webui.models.config import Config from open_webui.models.folders import ( FolderForm, FolderModel, @@ -23,17 +24,16 @@ from open_webui.models.folders import ( Folders, FolderUpdateForm, ) -from open_webui.models.access_grants import AccessGrants -from open_webui.models.automations import Automations from open_webui.models.groups import Groups +from open_webui.models.shared_chats import ChatShareMode from open_webui.models.users import Users -from open_webui.utils.access_control import has_permission +from open_webui.tasks import has_active_tasks from open_webui.utils.access_control import ( - filter_allowed_access_grants, + has_permission, + validate_access_grants, ) from open_webui.utils.access_control.files import can_read_all_folder_files, get_accessible_folder_files from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.tasks import has_active_tasks from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -555,12 +555,12 @@ async def update_folder_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - None, + resource_type='folder', db=db, ) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 6619842593..275fd50f0f 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -43,7 +43,7 @@ from open_webui.routers.retrieval import ( process_files_batch, ) from open_webui.storage.provider import Storage -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.json_codec import JSONCodec @@ -338,7 +338,7 @@ async def create_new_knowledge( user.id, user.role, form_data.access_grants, - 'sharing.public_knowledge', + resource_type='knowledge', ) knowledge = await Knowledges.insert_new_knowledge(user.id, form_data) @@ -910,7 +910,7 @@ async def create_external_knowledge( user.id, user.role, form_data.access_grants, - 'sharing.public_knowledge', + resource_type='knowledge', ) knowledge = await Knowledges.insert_new_knowledge( @@ -969,7 +969,7 @@ async def create_external_knowledge_source( user.id, user.role, form_data.access_grants, - 'sharing.public_knowledge', + resource_type='knowledge', ) connections = await _get_external_connections() @@ -1040,14 +1040,6 @@ async def update_external_knowledge_source( if not test_result.get('documents'): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query returned no results.') - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_knowledge', - ) - connections[idx] = connection await _set_external_connections(connections) @@ -1176,14 +1168,6 @@ async def update_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_knowledge', - ) - knowledge = await Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: # Re-embed knowledge base for semantic search @@ -1252,12 +1236,12 @@ async def update_knowledge_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_knowledge', + resource_type='knowledge', ) knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index b7e23e8cd3..f9e7ed74dc 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -28,7 +28,7 @@ from open_webui.env import ( ) from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.access_grants import AccessGrants, normalize_access_grants +from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config from open_webui.models.files import Files from open_webui.models.groups import Groups @@ -45,17 +45,22 @@ from open_webui.models.models import ( Models, ) from open_webui.storage.provider import Storage -from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission +from open_webui.utils.access_control import ( + filter_allowed_access_grants, + has_access, + has_permission, + validate_access_grants, +) from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.chat_variables import get_chat_variables_schema from open_webui.utils.models import get_all_models from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image from open_webui.utils.voice_avatar import ( - AVATAR_MAX_BYTES, ANIMATION_MAX_BYTES, - validate_voice_avatar, + AVATAR_MAX_BYTES, validate_voice_animation, + validate_voice_avatar, ) from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -410,7 +415,7 @@ async def create_new_model( user.id, user.role, form_data.access_grants, - 'sharing.public_models', + resource_type='model', ) await _check_model_controls(form_data, None, user, request) @@ -580,12 +585,10 @@ async def import_models( and existing_model.user_id != user.id and model_id not in writable_model_ids ): - log.warning( - 'import_models: user %s skipped model %s (no write access)', - user.id, - model_id, + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.MODEL_ID_TAKEN, ) - continue if ( user.role != 'admin' @@ -607,17 +610,6 @@ async def import_models( model_data['params'] = model_data.get('params', {}) updated_model = ModelForm(**{**existing_model.model_dump(), **model_data}) - # Only filter access_grants when explicitly provided - # in the payload to avoid altering existing ACLs on - # metadata-only imports. - if 'access_grants' in model_data: - updated_model.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - updated_model.access_grants, - 'sharing.public_models', - ) imported_model = updated_model else: # Insert new model @@ -648,19 +640,17 @@ async def import_models( base_model_ids.add(base_model_id.split(':', 1)[0]) if model_id in base_model_ids: - log.warning( - 'import_models: user %s skipped model %s (id belongs to a base model)', - user.id, - model_id, + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.MODEL_ID_TAKEN, ) - continue new_model.access_grants = await filter_allowed_access_grants( await Config.get('user.permissions'), user.id, user.role, new_model.access_grants, - 'sharing.public_models', + resource_type='model', ) imported_model = new_model @@ -1209,30 +1199,6 @@ async def update_model_by_id( form_data.meta.background_image_url = model.meta.background_image_url await _verify_background_image(form_data.meta.background_image_url, user, db, model.meta.background_image_url) - if form_data.access_grants is not None: - # The editor resends every stored grant, so re-checking them would strip sharing this user cannot re-create. - existing_access_grants = { - (grant.principal_type, grant.principal_id, grant.permission) for grant in model.access_grants - } - submitted_access_grants_map = { - (grant['principal_type'], grant['principal_id'], grant['permission']): grant - for grant in normalize_access_grants(form_data.access_grants) - } - preserved_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants - ] - new_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants - ] - - form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - new_access_grants, - 'sharing.public_models', - ) - await _check_model_controls(form_data, model, user, request) model = await Models.update_model_by_id(form_data.id, form_data, db=db, user_id=user.id) if model: @@ -1306,12 +1272,12 @@ async def update_model_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_models', + resource_type='model', ) await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 1308be67ae..c21b71cdf0 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -11,7 +11,7 @@ from open_webui.config import ( from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.access_grants import AccessGrantModel, AccessGrants +from open_webui.models.access_grants import AccessGrantModel, AccessGrants, has_public_write_access_grant from open_webui.models.chats import ChatForm, ChatResponse, Chats from open_webui.models.config import Config from open_webui.models.groups import Groups @@ -20,6 +20,7 @@ from open_webui.models.notes import ( NoteListResponse, NoteModel, Notes, + NoteUpdateForm, NoteUserResponse, ) from open_webui.models.users import UserModel, UserResponse, Users @@ -27,9 +28,9 @@ from open_webui.socket.main import leave_room_for_users, sio from open_webui.utils.access_control import ( filter_allowed_access_grants, has_permission, - has_public_read_access_grant, - has_public_write_access_grant, + validate_access_grants, ) +from open_webui.utils.access_control.files import has_access_to_note_files from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -232,7 +233,7 @@ async def create_new_note( user.id, user.role, form_data.access_grants, - 'sharing.public_notes', + resource_type='note', db=db, ) @@ -511,7 +512,7 @@ async def create_note_chat_by_id( async def update_note_by_id( request: Request, id: str, - form_data: NoteForm, + form_data: NoteUpdateForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -533,22 +534,15 @@ async def update_note_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - if form_data.access_grants is not None: - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_notes', - db=db, - ) - - previous_access_grants = note.access_grants + if ( + form_data.data + and 'files' in form_data.data + and not await has_access_to_note_files(note, form_data.data['files'], user, db=db) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: note = await Notes.update_note_by_id(id, form_data, db=db) - if form_data.access_grants is not None: - await leave_note_rooms_for_revoked_users(note, previous_access_grants, db=db) pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) note.is_pinned = note.id in pinned_note_ids @@ -613,12 +607,12 @@ async def update_note_access_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_notes', + resource_type='note', ) await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index ee96e53c99..55b06eb42a 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -1173,22 +1173,20 @@ RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = { 'message': {'type', 'role', 'content'}, 'function_call': {'type', 'call_id', 'name', 'arguments', 'id'}, 'function_call_output': {'type', 'call_id', 'output'}, + 'reasoning': {'type', 'id', 'summary', 'encrypted_content', 'content'}, } def _normalize_stored_item(item: dict) -> dict: - """Strip local-only fields from a stored output item before replaying it. + """Keep replayable fields for known output item types. - Open WebUI stores extra bookkeeping fields (``id``, ``status``, - ``started_at``, ``ended_at``, ``duration``, ``_tag_type``, - ``attributes``, ``summary``, etc.) that the Responses API does - not accept. This helper returns a copy containing only the - fields the API understands. + Stored items also contain UI bookkeeping and output-only metadata. + In particular, Azure rejects ``status`` on reasoning input items. """ item_type = item.get('type', '') allowed = RESPONSES_ALLOWED_FIELDS.get(item_type) if allowed is None: - # Unknown type — pass through as-is (e.g. reasoning, extension items). + # Unknown type — pass through as-is for provider extensions. return item if item_type == 'function_call': item = {**item, **get_provider_function(item)} @@ -1695,8 +1693,6 @@ async def embeddings(request: Request, form_data: dict, user): dict: OpenAI-compatible embeddings response. """ idx = 0 - # Prepare payload/body - body = JSONCodec.dumps(form_data) # Find correct backend url/key based on model model_id = form_data.get('model') # Check if model is already in app state cache to avoid expensive get_all_models() call @@ -1709,6 +1705,10 @@ async def embeddings(request: Request, form_data: dict, user): url, key, api_config = await get_openai_connection(idx) + form_data['model'] = strip_provider_model_prefix(form_data['model'], api_config.get('prefix_id')) + # Prepare payload/body + body = JSONCodec.dumps(form_data) + r = None streaming = False diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 664fc892a7..28b1e7b685 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -7,7 +7,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.access_grants import AccessGrants, normalize_access_grants +from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( @@ -23,7 +23,7 @@ from open_webui.models.prompts import ( Prompts, PromptUserResponse, ) -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -171,7 +171,7 @@ async def create_new_prompt( user.id, user.role, form_data.access_grants, - 'sharing.public_prompts', + resource_type='prompt', ) prompt = await Prompts.get_prompt_by_command(form_data.command, db=db) @@ -289,29 +289,6 @@ async def update_prompt_by_id( detail=ERROR_MESSAGES.COMMAND_TAKEN, ) - if form_data.access_grants is not None: - existing_access_grants = { - (grant.principal_type, grant.principal_id, grant.permission) for grant in prompt.access_grants - } - submitted_access_grants_map = { - (grant['principal_type'], grant['principal_id'], grant['permission']): grant - for grant in normalize_access_grants(form_data.access_grants) - } - preserved_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants - ] - new_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants - ] - - form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - new_access_grants, - 'sharing.public_prompts', - ) - # Use the ID from the found prompt updated_prompt = await Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db) if updated_prompt: @@ -484,12 +461,12 @@ async def update_prompt_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_prompts', + resource_type='prompt', ) await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 0f38985cff..fba5668d34 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -37,7 +37,6 @@ from langchain_text_splitters import ( from open_webui.config import ( Config, DEFAULT_LOCALE, - ENV, RAG_EMBEDDING_CONTENT_PREFIX, RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, @@ -1575,8 +1574,26 @@ async def process_file( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): + """Standalone processing endpoint: index the file, then mark it completed. + + Uploads call index_file directly so they can finish knowledge linking before + reporting completion. """ - Process a file and save its content to the vector database. + result = await index_file(request, form_data, user, db) + async with get_async_db() as session: + await Files.update_file_data_by_id(form_data.file_id, {'status': 'completed', 'error': None}, db=session) + return result + + +async def index_file( + request: Request, + form_data: ProcessFileForm, + user, + db: AsyncSession, +): + """Extract or reuse file content and index it without marking completion. + + The caller marks completion after all its work, including any knowledge linking. Note: granular session management is used to prevent connection pool exhaustion. The session is committed before external API calls, and updates use a fresh session. """ @@ -1714,7 +1731,6 @@ async def process_file( hash = calculate_sha256_string(text_content) if config['rag.bypass_embedding_and_retrieval']: - await Files.update_file_data_by_id(file.id, {'status': 'completed', 'error': None}, db=db) await Files.update_file_hash_by_id(file.id, hash, db=db) await publish_event( request, @@ -1771,11 +1787,6 @@ async def process_file( db=session, ) - await Files.update_file_data_by_id( - file.id, - {'status': 'completed', 'error': None}, - db=session, - ) await Files.update_file_hash_by_id(file.id, hash, db=session) await publish_event( @@ -2990,13 +3001,6 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo return True -if ENV == 'dev': - - @router.get('/ef/{text}') - async def get_embeddings(request: Request, text: str | None = 'Hello World!'): - return {'result': await request.app.state.EMBEDDING_FUNCTION(text, prefix=RAG_EMBEDDING_QUERY_PREFIX)} - - class BatchProcessFilesForm(BaseModel): files: list[FileModel] collection_name: str diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index ff6ce5159b..d8ffb87f1d 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -28,7 +28,7 @@ from open_webui.models.skills import ( SkillUserResponse, get_skill_snapshot, ) -from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.skill_files import ( MAX_IMPORT_BYTES, @@ -261,7 +261,7 @@ async def create_new_skill( user.id, user.role, form_data.access_grants, - 'sharing.public_skills', + resource_type='skill', ) try: @@ -373,22 +373,9 @@ async def update_skill_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - # Strip public/user grants the requesting user is not permitted to assign - # (matches the channel/notes/calendar pattern). The access check above only - # restricts WHO can write to the skill; this filter restricts WHICH grants - # they may set, so a non-admin owner cannot make their own skill publicly - # readable/writable without sharing.public_skills permission. - form_data.access_grants = await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - form_data.access_grants, - 'sharing.public_skills', - ) - try: updated = { - **form_data.model_dump(exclude={'id'}, exclude_unset=True), + **form_data.model_dump(exclude={'id', 'access_grants'}, exclude_unset=True), } skill = await Skills.update_skill_by_id(id, updated, db=db, user_id=user.id) @@ -456,12 +443,12 @@ async def update_skill_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_skills', + resource_type='skill', ) await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 19bc3fc67e..7f67956a25 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -19,7 +19,7 @@ from open_webui.env import ( ) from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.access_grants import AccessGrants, normalize_access_grants +from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions @@ -36,6 +36,7 @@ from open_webui.utils.access_control import ( filter_allowed_access_grants, has_connection_access, has_permission, + validate_access_grants, ) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.plugin import ( @@ -420,7 +421,7 @@ async def create_new_tools( user.id, user.role, form_data.access_grants, - 'sharing.public_tools', + resource_type='tool', ) form_data.content = replace_imports(form_data.content) @@ -571,37 +572,11 @@ async def _update_tool(request, id, form_data, user, db, version_id=None): specs = get_local_tool_specs(tool_module) - if version_id is None and form_data.access_grants is not None: - existing_access_grants = { - (grant.principal_type, grant.principal_id, grant.permission) for grant in tools.access_grants - } - submitted_access_grants_map = { - (grant['principal_type'], grant['principal_id'], grant['permission']): grant - for grant in normalize_access_grants(form_data.access_grants) - } - preserved_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants - ] - new_access_grants = [ - grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants - ] - - form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants( - await Config.get('user.permissions'), - user.id, - user.role, - new_access_grants, - 'sharing.public_tools', - ) - updated = { - **form_data.model_dump(exclude={'id'}), + **form_data.model_dump(exclude={'id', 'access_grants'}), 'specs': specs, } - if version_id is not None: - updated.pop('access_grants', None) - tools = await Tools.update_tool_by_id( id, updated, @@ -677,12 +652,12 @@ async def update_tool_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.access_grants = await filter_allowed_access_grants( + form_data.access_grants = await validate_access_grants( await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, - 'sharing.public_tools', + resource_type='tool', ) await AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 90a327e3fd..4d5f35e2c3 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -202,6 +202,7 @@ class WorkspacePermissions(BaseModel): skills: bool = False models_import: bool = False models_export: bool = False + models_hidden: bool = False prompts_import: bool = False prompts_export: bool = False tools_import: bool = False @@ -514,16 +515,6 @@ async def update_user_settings_by_session_user( ): ui_settings.pop('system', None) - if ( - user.role != 'admin' - and 'params' in ui_settings - and ( - not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db) - or not await has_permission(user.id, 'chat.params', await Config.get('user.permissions'), db=db) - ) - ): - ui_settings.pop('params', None) - if ( user.role != 'admin' and ui_settings is not None diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 09937e9171..2d235175cf 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -54,6 +54,7 @@ from open_webui.tasks import ( stop_item_tasks, ) from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control.files import has_access_to_note_files from open_webui.utils.auth import get_verified_user_by_token from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.json_codec import SOCKETIO_JSON, JSONCodec, dumps_bytes @@ -440,11 +441,6 @@ def get_session_ids_from_room(room): return list(members) if members else [] -def get_session_ids_by_user_id(user_id: str) -> list[str]: - """Get known session IDs for a user across the local rooms and shared session pool.""" - return get_session_ids_by_user_ids([user_id]) - - def get_session_ids_by_user_ids(user_ids: list[str]) -> list[str]: """Get known session IDs for users across the local rooms and shared session pool.""" if not user_ids: @@ -517,7 +513,14 @@ async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False The client will automatically reconnect and re-authenticate with fresh data from the database. """ - session_ids = get_session_ids_by_user_id(user_id) + session_ids = await disconnect_sessions_for_users([user_id], refresh_access=refresh_access) + if session_ids: + log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id) + + +async def disconnect_sessions_for_users(user_ids: list[str], *, refresh_access: bool = False) -> list[str]: + """Disconnect all Socket.IO sessions belonging to these users and return their session IDs.""" + session_ids = get_session_ids_by_user_ids(user_ids) for sid in session_ids: if refresh_access: try: @@ -527,10 +530,8 @@ async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False try: await sio.disconnect(sid) except Exception: - log.exception('Failed to disconnect session %s for user %s', sid, user_id) - - if session_ids: - log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id) + log.exception('Failed to disconnect session %s', sid) + return session_ids @sio.on('usage') @@ -622,12 +623,23 @@ async def refresh_chat_access(chat_id=None): access = {} chat_ids = set() for batch in get_session_pool_batches(): + chat_owner_ids: dict[str, str] = {} + if not chat_id: + # Owners keep access; role changes and deletion disconnect them separately. + open_chat_ids: set[str] = set() + for _, session in batch: + if not session: + continue + open_chat_ids.update(session.get('chat_ids') or []) + chat_owner_ids = await Chats.get_owner_ids_by_chat_ids(list(open_chat_ids)) for sid, session in batch: if not session: continue for cid in session.get('chat_ids') or []: if chat_id and cid != chat_id: continue + if chat_owner_ids.get(cid) == session['id']: + continue key = (cid, session['id']) if key not in access: user = await Users.get_user_by_id(session['id']) @@ -939,8 +951,6 @@ async def ydoc_document_join(sid, data): # Join Socket.IO room await sio.enter_room(sid, f'doc_{document_id}') - active_session_ids = get_session_ids_from_room(f'doc_{document_id}') - # Get the Yjs document state ydoc = Y.Doc() updates = await YDOC_MANAGER.get_updates(document_id) @@ -955,7 +965,6 @@ async def ydoc_document_join(sid, data): 'document_id': document_id, 'state': list(state_update), # Convert bytes to list for JSON 'content': note.data.get('content') if document_id.startswith('note:') and note.data else None, - 'sessions': active_session_ids, }, room=sid, ) @@ -1089,8 +1098,25 @@ async def yjs_document_update(sid, data): log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') return + snapshot = data.get('data') + if isinstance(snapshot, dict) and 'files' in snapshot: + editor = await Users.get_user_by_id(user['id']) + if not editor or not await has_access_to_note_files(note, snapshot['files'], editor): + await sio.emit('error', {'message': 'Cannot attach files you cannot access'}, room=sid) + return + update = data.get('update') # List of bytes from frontend + # The document lock makes initialization first-write-wins across workers. + # Return the stored state so losing clients never apply their own seed. + if data.get('initialize'): + updates = await YDOC_MANAGER.get_updates(document_id) + if updates: + ydoc = Y.Doc() + for stored_update in updates: + ydoc.apply_update(bytes(stored_update)) + return list(ydoc.get_update()) + if update: user_id = data.get('user_id', sid) @@ -1114,6 +1140,9 @@ async def yjs_document_update(sid, data): else: log.warning(f'Update for document {document_id} is invalid or over the size limit. Rejecting update.') + if data.get('initialize'): + return update if stored else None + async def debounced_save(): await asyncio.sleep(0.5) async with YDOC_MANAGER.lock(document_id): @@ -1574,6 +1603,20 @@ async def get_event_emitter(request_info, update_db=True): return None +def get_user_chat_session_id(user_id: str, chat_id: str) -> str | None: + now = time.time() + for batch in get_session_pool_batches(): + for session_id, session in batch: + if ( + session + and session.get('id') == user_id + and chat_id in (session.get('chat_ids') or []) + and now - session.get('last_seen_at', 0) <= SESSION_POOL_TIMEOUT + ): + return session_id + return None + + async def get_event_call(request_info): async def __event_caller__(event_data): session_id = request_info['session_id'] @@ -1584,12 +1627,17 @@ async def get_event_call(request_info): log.warning(f'Event caller: session {session_id} not owned by requesting user or disconnected') return {'error': 'Client session disconnected.'} - interaction_id = None + is_input_request = event_data.get('type') in { + 'confirmation', + 'input', + 'request:user_input', + 'request:elicitation', + } + interaction_id = str(uuid4()) if is_input_request else None timeout = WEBSOCKET_EVENT_CALLER_TIMEOUT if event_data.get('type') in ('request:user_input', 'request:elicitation') or ( event_data.get('type') == 'confirmation' and (event_data.get('data') or {}).get('tool_call') ): - interaction_id = str(uuid4()) data = dict(event_data.get('data') or {}) timeout_ms = data.get('timeout_ms', 120_000) if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int): @@ -1597,19 +1645,56 @@ async def get_event_call(request_info): timeout = min(max(timeout_ms / 1000, 60), 240) if WEBSOCKET_EVENT_CALLER_TIMEOUT is not None and WEBSOCKET_EVENT_CALLER_TIMEOUT > 0: timeout = min(timeout, WEBSOCKET_EVENT_CALLER_TIMEOUT) - event_data = {**event_data, 'data': {**data, 'interaction_id': interaction_id}} + if interaction_id: + event_data = {**event_data, 'data': {**(event_data.get('data') or {}), 'interaction_id': interaction_id}} + can_resume = ( + is_input_request + and 'session_id' not in (event_data.get('data') or {}) + and is_saved_chat_id(request_info['chat_id']) + ) try: - return await sio.call( - 'events', - { - 'chat_id': request_info.get('chat_id', None), - 'message_id': request_info.get('message_id', None), - 'data': event_data, - }, - to=session_id, - timeout=timeout, - ) + async with asyncio.timeout(timeout): + while True: + call_task = asyncio.create_task( + sio.call( + 'events', + { + 'chat_id': request_info['chat_id'], + 'message_id': request_info['message_id'], + 'data': event_data, + }, + to=session_id, + timeout=None, + ) + ) + try: + while ( + not call_task.done() + and get_user_id_from_session_pool(session_id) == request_info['user_id'] + ): + await asyncio.wait({call_task}, timeout=1) + if call_task.done(): + return call_task.result() + finally: + call_task.cancel() + await asyncio.gather(call_task, return_exceptions=True) + log.warning('Event caller: session %s disconnected before answering', session_id) + if not can_resume: + return {'error': 'Client session disconnected.'} + try: + async with asyncio.timeout(60): + while True: + # Redis session scans are synchronous; keep them off the event loop. + next_session_id = await asyncio.to_thread( + get_user_chat_session_id, request_info['user_id'], request_info['chat_id'] + ) + if next_session_id is not None: + session_id = request_info['session_id'] = next_session_id + break + await asyncio.sleep(1) + except TimeoutError: + return {'error': 'Client session disconnected.'} except (TimeoutError, socketio.exceptions.TimeoutError): log.warning(f'Event caller timed out for session {session_id}') return {'error': 'Event call timed out. The browser tab may be inactive or closed.'} diff --git a/backend/open_webui/socket/redis_room_channels.py b/backend/open_webui/socket/redis_room_channels.py index 0ad8c9aa5f..5677509985 100644 --- a/backend/open_webui/socket/redis_room_channels.py +++ b/backend/open_webui/socket/redis_room_channels.py @@ -111,6 +111,9 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager): if data.get('type') == 'subscribe': # redis-py can reconnect internally and restore a pending removal. self._subscription_queue.put_nowait(asyncio.Event()) + # Ping once per (re)subscribe, not per channel: a silent reconnect drops the pending PONG. + if data['channel'] == self.channel.encode(): + await pubsub.ping(str(serial).encode()) elif data.get('type') == 'pong' and data['data'] == str(serial).encode(): for ready in waiters: ready.set() diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index dcbdfe5201..76744185ec 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -539,7 +539,9 @@ async def ask_user( :return: JSON with status and answers keyed by question id """ try: - if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + if not isinstance(questions, list): + raise ValueError('ask_user questions must be an array of question objects.') + if not 1 <= len(questions) <= 3: raise ValueError('ask_user requires 1-3 questions.') normalized_questions = [] @@ -1348,21 +1350,26 @@ async def replace_note_content( __user__: dict = None, ) -> str: """ - Update an existing note by replacing the whole markdown content or applying range operations. + Update an existing note by replacing the whole markdown content, exact text, or character ranges. - Prefer "replace_range" when only part of the note changes. + Prefer "replace_text" when only part of the note changes. Supply either find or start/end, not both. + find is non-empty, literal, and case-sensitive. + Text matches are non-overlapping. Multiple matches require a 0-indexed occurrence or replace_all=true; + otherwise nothing is changed and match offsets and context are returned. A "replace" operation must be the only operation in the request. start and end are 0-indexed character offsets into the markdown content from view_note. end is exclusive. Offsets never shift as operations are applied. Ranges must not overlap. expected is optional. When set, the request is rejected if the range's current text does not match it. + "replace_range" is a deprecated alias for the start/end form of "replace_text". :param note_id: The ID of the note to update :param content: The new markdown content for a whole-note update :param operations: Optional note operations: - {"action": "replace", "content": "..."} - - {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."} + - {"action": "replace_text", "start": 0, "end": 10, "content": "...", "expected": "..."} + - {"action": "replace_text", "find": "old text", "content": "new text", "occurrence": 0, "replace_all": false} :param title: Optional new title for the note :return: JSON with success status and updated note info """ @@ -1386,11 +1393,11 @@ async def replace_note_content( current_content = ((note.data or {}).get('content') or {}).get('md') or '' applied_operation_count = 0 + range_operations = [] if operations is not None: if not isinstance(operations, list) or len(operations) == 0: return JSONCodec.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'}) - range_operations = [] for idx, operation in enumerate(operations): if not isinstance(operation, dict): return JSONCodec.dumps( @@ -1421,11 +1428,78 @@ async def replace_note_content( applied_operation_count = 1 break - if action != 'replace_range': + if action not in ('replace_range', 'replace_text'): return JSONCodec.dumps( {'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action} ) + if not isinstance(replacement, str): + return JSONCodec.dumps( + {'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx} + ) + if action == 'replace_text' and 'find' in operation: + if 'start' in operation or 'end' in operation: + return JSONCodec.dumps( + { + 'error': 'use either find or start/end, not both', + 'code': 'invalid_operation', + 'index': idx, + } + ) + find = operation.get('find') + occurrence = operation.get('occurrence') + replace_all = operation.get('replace_all', False) + if not isinstance(find, str) or not find: + return JSONCodec.dumps( + {'error': 'find must be a non-empty string', 'code': 'invalid_find', 'index': idx} + ) + if ( + type(replace_all) is not bool + or (occurrence is not None and (type(occurrence) is not int or occurrence < 0)) + or (replace_all and occurrence is not None) + ): + return JSONCodec.dumps( + { + 'error': 'use a non-negative occurrence or replace_all boolean', + 'code': 'invalid_occurrence', + 'index': idx, + } + ) + + matches = [] + start = current_content.find(find) + while start != -1: + end = start + len(find) + matches.append({'start': start, 'end': end}) + start = current_content.find(find, end) + + if not matches: + return JSONCodec.dumps({'error': 'Text not found', 'code': 'text_not_found', 'index': idx}) + if occurrence is not None and occurrence >= len(matches): + return JSONCodec.dumps( + {'error': 'occurrence is out of bounds', 'code': 'invalid_occurrence', 'index': idx} + ) + if len(matches) > 1 and occurrence is None and not replace_all: + return JSONCodec.dumps( + { + 'error': 'Multiple matches; specify occurrence or replace_all', + 'code': 'ambiguous_match', + 'index': idx, + 'matches': [ + { + **match, + 'context': current_content[max(0, match['start'] - 50) : match['end'] + 50], + } + for match in matches + ], + }, + ensure_ascii=False, + ) + if occurrence is not None: + matches = [matches[occurrence]] + range_operations.extend({**match, 'content': replacement} for match in matches) + continue + start = operation.get('start') end = operation.get('end') expected = operation.get('expected') @@ -1433,10 +1507,6 @@ async def replace_note_content( return JSONCodec.dumps( {'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx} ) - if not isinstance(replacement, str): - return JSONCodec.dumps( - {'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx} - ) if start < 0 or end < start or end > len(current_content): return JSONCodec.dumps( {'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx} @@ -1503,6 +1573,7 @@ async def replace_note_content( 'title': updated_note.title, 'updated_at': updated_note.updated_at, 'applied_operation_count': applied_operation_count, + 'applied_ranges': [{'start': op['start'], 'end': op['end']} for op in range_operations], }, ensure_ascii=False, ) @@ -2773,7 +2844,7 @@ async def view_file( :param max_chars: Maximum characters to return (a server-side hard cap applies) :param line_numbers: If true, prefix each line with its 1-indexed line number :param start_line: Optional 1-indexed start line (overrides offset/max_chars when set) - :param end_line: Optional 1-indexed end line (inclusive) + :param end_line: Optional 1-indexed end line (inclusive; reads from line 1 if no start line is given) :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: @@ -2815,10 +2886,10 @@ async def view_file( total_chars = len(content) # Line-based addressing (overrides char-based offset/max_chars) - if start_line is not None: + if start_line is not None or end_line is not None: all_lines = content.split('\n') total_lines = len(all_lines) - s = max(1, int(start_line)) - 1 # 1-indexed to 0-indexed + s = max(1, int(start_line or 1)) - 1 # 1-indexed to 0-indexed e = min(total_lines, int(end_line) if end_line else s + 100) selected = all_lines[s:e] sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected)) @@ -2838,7 +2909,8 @@ async def view_file( return JSONCodec.dumps(result, ensure_ascii=False) sliced = content[offset : offset + max_chars] - is_truncated = (offset + len(sliced)) < total_chars + returned_chars = len(sliced) + is_truncated = (offset + returned_chars) < total_chars if line_numbers: start_ln = content[:offset].count('\n') + 1 @@ -2856,10 +2928,10 @@ async def view_file( if is_truncated or offset > 0: result['truncated'] = is_truncated result['total_chars'] = total_chars - result['returned_chars'] = len(sliced) + result['returned_chars'] = returned_chars result['offset'] = offset if is_truncated: - result['next_offset'] = offset + len(sliced) + result['next_offset'] = offset + returned_chars return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: @@ -2885,7 +2957,7 @@ async def view_knowledge_file( :param max_chars: Maximum characters to return (a server-side hard cap applies) :param line_numbers: If true, prefix each line with its 1-indexed line number :param start_line: Optional 1-indexed start line (overrides offset/max_chars when set) - :param end_line: Optional 1-indexed end line (inclusive) + :param end_line: Optional 1-indexed end line (inclusive; reads from line 1 if no start line is given) :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: @@ -2955,10 +3027,10 @@ async def view_knowledge_file( total_chars = len(content) # Line-based addressing (overrides char-based offset/max_chars) - if start_line is not None: + if start_line is not None or end_line is not None: all_lines = content.split('\n') total_lines = len(all_lines) - s = max(1, int(start_line)) - 1 + s = max(1, int(start_line or 1)) - 1 e = min(total_lines, int(end_line) if end_line else s + 100) selected = all_lines[s:e] sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected)) @@ -2981,7 +3053,8 @@ async def view_knowledge_file( return JSONCodec.dumps(result, ensure_ascii=False) sliced = content[offset : offset + max_chars] - is_truncated = (offset + len(sliced)) < total_chars + returned_chars = len(sliced) + is_truncated = (offset + returned_chars) < total_chars if line_numbers: start_ln = content[:offset].count('\n') + 1 @@ -3002,10 +3075,10 @@ async def view_knowledge_file( if is_truncated or offset > 0: result['truncated'] = is_truncated result['total_chars'] = total_chars - result['returned_chars'] = len(sliced) + result['returned_chars'] = returned_chars result['offset'] = offset if is_truncated: - result['next_offset'] = offset + len(sliced) + result['next_offset'] = offset + returned_chars return JSONCodec.dumps(result, ensure_ascii=False) except Exception as e: diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 03dad016f1..c9a9d2c69d 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -2,14 +2,11 @@ import logging from copy import deepcopy from typing import Any +from fastapi import HTTPException from open_webui.config import DEFAULT_USER_PERMISSIONS from open_webui.models.access_grants import ( - has_anyone_read_access_grant, - has_public_read_access_grant, - has_public_write_access_grant, - has_user_access_grant, + normalize_access_grants, strip_anyone_access_grants, - strip_user_access_grants, ) from open_webui.models.groups import Groups from open_webui.models.users import UserModel @@ -218,85 +215,91 @@ def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_ke data.pop(ac_key, None) +# General sharing, all authenticated users, and anonymous readers, respectively. +_SHARING_PERMISSION_KEYS = { + 'model': ('sharing.models', 'sharing.public_models', None), + 'knowledge': ('sharing.knowledge', 'sharing.public_knowledge', None), + 'prompt': ('sharing.prompts', 'sharing.public_prompts', None), + 'tool': ('sharing.tools', 'sharing.public_tools', None), + 'skill': ('sharing.skills', 'sharing.public_skills', None), + 'note': ('sharing.notes', 'sharing.public_notes', None), + 'folder': ('sharing.folders', None, None), + 'channel': (None, 'sharing.public_channels', None), + 'calendar': (None, 'sharing.public_calendars', None), + 'shared_chat': (None, 'sharing.public_chats', 'sharing.open_chats'), +} + + async def filter_allowed_access_grants( default_permissions: dict[str, Any], user_id: str, user_role: str, - access_grants: list, - public_permission_key: str, - anyone_permission_key: str | None = None, + access_grants: list | None, + resource_type: str, + *, db: AsyncSession | None = None, -) -> list: - """ - Checks if the user has the required permissions to grant access to a resource. - Returns the filtered list of access grants if permissions are missing. - """ +) -> list | None: + """Filter initial sharing by the resource policy when creating an item.""" + if resource_type not in _SHARING_PERMISSION_KEYS: + raise ValueError(f'Unsupported sharing resource type: {resource_type}') + sharing_permission_key, public_permission_key, anyone_permission_key = _SHARING_PERMISSION_KEYS[resource_type] + if not access_grants: return access_grants - if has_anyone_read_access_grant(access_grants) and ( - not anyone_permission_key - or ( - user_role != 'admin' - and not await has_permission( - user_id, - anyone_permission_key, - default_permissions, - db=db, - ) - ) - ): + access_grants = normalize_access_grants(access_grants) + # Unsupported audiences are forbidden even for admins. + if anyone_permission_key is None: access_grants = strip_anyone_access_grants(access_grants) + if public_permission_key is None: + access_grants = [ + grant for grant in access_grants if (grant['principal_type'], grant['principal_id']) != ('user', '*') + ] - if user_role == 'admin': + if user_role == 'admin' or not access_grants: return access_grants - # Check if user can share publicly - if ( - has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants) - ) and not await has_permission( - user_id, - public_permission_key, - default_permissions, - db=db, - ): - access_grants = [ - grant - for grant in access_grants - if not ( - (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) - == 'user' - and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) - == '*' - ) - ] + permissions = await get_permissions(user_id, default_permissions, db=db) - # Strip individual user sharing if user lacks permission - if has_user_access_grant(access_grants) and not await has_permission( - user_id, - 'access_grants.allow_users', - default_permissions, - db=db, - ): - access_grants = strip_user_access_grants(access_grants) + def allowed(key: str | None) -> bool: + if key is None: + return False + section, name = key.split('.') + return bool(permissions.get(section, {}).get(name, False)) - if any( - (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group' + if sharing_permission_key and not allowed(sharing_permission_key): + return [] + + allowed_audiences = { + 'anyone': allowed(anyone_permission_key), + 'public': allowed(public_permission_key), + 'user': allowed('access_grants.allow_users'), + 'group': allowed('access_grants.allow_groups'), + } + return [ + grant for grant in access_grants - ) and not await has_permission( - user_id, - 'access_grants.allow_groups', - default_permissions, - db=db, - ): - access_grants = [ - grant - for grant in access_grants - if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) - != 'group' + if allowed_audiences[ + 'public' if (grant['principal_type'], grant['principal_id']) == ('user', '*') else grant['principal_type'] ] + ] - return access_grants + +async def validate_access_grants( + default_permissions: dict[str, Any], + user_id: str, + user_role: str, + access_grants: list, + resource_type: str, + *, + db: AsyncSession | None = None, +) -> list: + """Reject unauthorized sharing changes before replacing any stored grants.""" + grants = normalize_access_grants(access_grants) + allowed = await filter_allowed_access_grants(default_permissions, user_id, user_role, grants, resource_type, db=db) + if len(allowed) != len(grants): + raise HTTPException(403, 'You do not have permission to share with the requested audience') + return allowed async def has_base_model_access( diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index a30e482215..c8a10910da 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -8,7 +8,7 @@ from open_webui.models.folders import FolderModel from open_webui.models.groups import Groups from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models -from open_webui.models.notes import Notes +from open_webui.models.notes import NoteModel, Notes from open_webui.models.users import UserModel, Users from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +17,35 @@ log = logging.getLogger(__name__) FOLDER_FILE_TYPES = {'file', 'collection', 'note'} +def get_note_file_ids(entries: list[dict] | None) -> set[str]: + if not isinstance(entries, list): + return set() + return { + entry['id'] + for entry in entries + if isinstance(entry, dict) and entry.get('type') == 'file' and isinstance(entry.get('id'), str) and entry['id'] + } + + +async def has_access_to_note_files( + note: NoteModel, entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None +) -> bool: + """Check the editor's read access to newly attached files.""" + if entries is not None and not isinstance(entries, list): + return False + + # Existing attachments may belong to another editor; only additions need permission. + added_ids = get_note_file_ids(entries) - get_note_file_ids((note.data or {}).get('files')) + if not added_ids or user.role == 'admin': + return True + + group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)} + for file_id in added_ids: + if not await has_access_to_file(file_id, 'read', user, db=db, user_group_ids=group_ids): + return False + return True + + async def has_access_to_file( file_id: str | None, access_type: str, diff --git a/backend/open_webui/utils/access_control/folders.py b/backend/open_webui/utils/access_control/folders.py index 1a632ba01b..8bb2433309 100644 --- a/backend/open_webui/utils/access_control/folders.py +++ b/backend/open_webui/utils/access_control/folders.py @@ -32,3 +32,28 @@ async def has_folder_write_access(user_id: str, folder_id: str, db: AsyncSession if not folder: return False return await has_folder_access(user_id, folder, 'write', db) + + +async def has_chat_move_access( + user_id: str, source_folder_id: str | None, target_folder_id: str | None, db: AsyncSession | None = None +) -> bool: + """Only the owner may reorganize another user's chat within the same folder tree.""" + + async def get_owned_folder_root_id(folder_id): + seen_ids = set() + shared_root_id = None + while folder_id and folder_id not in seen_ids: + seen_ids.add(folder_id) + folder = await Folders.get_folder_by_id(folder_id, db=db) + if not folder or folder.user_id != user_id: + return None + if await AccessGrants.get_grants_by_resource('folder', folder.id, db=db): + shared_root_id = folder.id + if not folder.parent_id: + # A private parent must not let moves escape a shared subtree. + return shared_root_id or folder.id + folder_id = folder.parent_id + return None + + source_root_id = await get_owned_folder_root_id(source_folder_id) + return source_root_id is not None and source_root_id == await get_owned_folder_root_id(target_folder_id) diff --git a/backend/open_webui/utils/ask_user.py b/backend/open_webui/utils/ask_user.py index 14f5da6ad0..94bc648ef2 100644 --- a/backend/open_webui/utils/ask_user.py +++ b/backend/open_webui/utils/ask_user.py @@ -24,7 +24,9 @@ def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | N def normalize_ask_user_request(arguments: dict) -> dict: questions = arguments.get('questions') - if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + if not isinstance(questions, list): + raise ValueError('ask_user questions must be an array of question objects.') + if not 1 <= len(questions) <= 3: raise ValueError('ask_user requires 1-3 questions.') normalized_questions = [] diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index b1d996eca6..856bdd2d99 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -27,6 +27,7 @@ from loguru import logger from open_webui.env import AUDIT_INCLUDED_PATHS, AUDIT_LOG_LEVEL, ENABLE_AUDIT_GET_REQUESTS, MAX_BODY_LOG_SIZE from open_webui.models.users import UserModel from open_webui.utils.auth import get_current_user, get_http_authorization_cred +from open_webui.utils.response import normalize_usage from starlette.requests import Request if TYPE_CHECKING: @@ -44,6 +45,7 @@ class AuditLogEntry: user_agent: Optional[str] = None source_ip: Optional[str] = None model_id: Optional[str] = None + usage: Optional[Dict[str, int]] = None # `Request` audit level properties request_object: Any = None # `Request Response` level @@ -114,6 +116,31 @@ class AuditContext: self.response_body.extend(chunk[: self.max_body_size - len(self.response_body)]) +def log_model_usage(request: Request, user: UserModel, model_id: str, usage: Optional[Dict]): + """Write a model call's usage separately from its originating HTTP request.""" + request_id = getattr(request.state, 'audit_request_id', None) + if not request_id or not usage: + return + try: + usage = normalize_usage(usage) + AuditLogger(logger).write( + AuditLogEntry( + id=str(uuid.uuid4()), + user=user.model_dump(include={'id', 'name', 'email', 'role'}), + audit_level=request.state.audit_level, + verb=request.method, + request_uri=str(request.url), + source_ip=request.client.host if request.client else None, + user_agent=request.headers.get('user-agent'), + model_id=model_id, + usage={key: usage[key] for key in ('input_tokens', 'output_tokens', 'total_tokens')}, + ), + extra={'event': 'model_usage', 'request_id': request_id}, + ) + except Exception as e: + logger.error('Failed to log model usage: {}', e) + + def redact_passwords(body: str) -> str: if 'password' not in body.lower(): return body @@ -185,6 +212,8 @@ class AuditLoggingMiddleware: if self._should_skip_auditing(request): return await self.app(scope, receive, send) + request.state.audit_request_id = str(uuid.uuid4()) + request.state.audit_level = self.audit_level.value capture_body = not (request.url.path.startswith('/api/v1/auths') or request.url.path.startswith('/oauth/')) async with self._audit_context(request) as context: @@ -305,7 +334,7 @@ class AuditLoggingMiddleware: response_body = redact_passwords(response_body) entry = AuditLogEntry( - id=str(uuid.uuid4()), + id=request.state.audit_request_id, user=user, audit_level=self.audit_level.value, verb=request.method, diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 7f9649bdd7..87aa9335ca 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -61,7 +61,7 @@ async def compact_messages_for_request( messages, previous_summary = _apply_latest_summary_checkpoint(messages) system_prompt = system_prompt or (get_content_from_message(system_messages[0]) if system_messages else '') token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata) - if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3: + if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold): return [*system_messages, *messages], previous_summary, False boundary = _find_compaction_boundary(messages, config['retention_percentage']) @@ -145,6 +145,10 @@ async def compact_messages_for_request( 'action': 'context_compaction', 'description': 'Context compacted', 'done': True, + 'checkpoint_message_id': ( + recent_messages[0].get('id') if recent_messages[0].get('role') == 'user' else None + ), + 'context_summary': summary, }, } ) @@ -374,6 +378,8 @@ def _find_compaction_boundary(messages: list[dict], retention_percentage: int = ][1:] else: boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:] + # The first later user turn stays a valid boundary even inside the retained share. + target = max(target, min(boundaries, default=0)) return next((idx for idx in reversed(boundaries) if idx <= target), 0) diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 00262df0f1..09d0683c9e 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -19,6 +19,7 @@ from open_webui.env import ( FORWARD_USER_INFO_HEADER_USER_ROLE, ) from open_webui.models.groups import Groups +from open_webui.utils.oauth import get_system_oauth_token log = logging.getLogger(__name__) @@ -208,16 +209,7 @@ async def get_headers_and_cookies( elif auth_type == 'session': token = request.state.token.credentials elif auth_type == 'system_oauth': - oauth_token = None - try: - if request.cookies.get('oauth_session_id', None): - oauth_token = await request.app.state.oauth_manager.get_oauth_token( - user.id, - request.cookies.get('oauth_session_id', None), - ) - except Exception as e: - log.error(f'Error getting OAuth token: {e}') - + oauth_token = await get_system_oauth_token(request, user) if user else None if oauth_token: token = f'{oauth_token.get("access_token", "")}' diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 3ab517f396..8cfc803582 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -157,6 +157,7 @@ def file_format(record: 'Record'): 'source_ip': record['extra'].get('source_ip', ''), 'user_agent': record['extra'].get('user_agent', ''), 'model_id': record['extra'].get('model_id', ''), + 'usage': record['extra'].get('usage'), 'request_object': record['extra'].get('request_object', b''), 'response_object': record['extra'].get('response_object', b''), 'extra': record['extra'].get('extra', {}), diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index cd5e7b301c..15bd5ed46b 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -1,68 +1,47 @@ import asyncio import logging +import math from contextlib import AsyncExitStack -from datetime import timedelta from typing import Optional from urllib.parse import urlsplit -log = logging.getLogger(__name__) - -import anyio -import httpx +import httpx2 from jsonschema import Draft202012Validator, FormatChecker -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp import Client +from mcp.client.streamable_http import streamable_http_client from mcp.types import ElicitRequestFormParams, ElicitResult -from referencing import Registry from open_webui.env import ( AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, MCP_INITIALIZE_TIMEOUT, ) from open_webui.utils.json_codec import JSONCodec +from referencing import Registry + +log = logging.getLogger(__name__) -def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True): - """Create an httpx AsyncClient for MCP transport. +def create_httpx_client(headers=None, auth=None): + """Create the httpx2 AsyncClient that carries MCP traffic. - Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller - (i.e. the MCP SDK) does not supply an explicit timeout. + Note: verify must be passed at construction time because httpx2 + configures the SSL context during __init__. AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL + may be True, False, or an ssl.SSLContext (when a custom CA bundle path is + configured); verify= accepts all three. - Note: verify must be passed at construction time because httpx - configures the SSL context during __init__. Setting client.verify = False - after construction does not affect the underlying transport's SSL context. + Redirects are not configured here: the MCP SDK follows same-origin + redirects itself and ignores the client's follow_redirects setting. """ - kwargs = { - 'follow_redirects': True, - 'verify': verify, - } - if timeout is not None: - kwargs['timeout'] = timeout - elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None: - kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) - if headers is not None: - kwargs['headers'] = headers - if auth is not None: - kwargs['auth'] = auth - return httpx.AsyncClient(**kwargs) + return httpx2.AsyncClient( + headers=headers, + auth=auth, + verify=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + # The SDK's transport defaults: reads stay open for long-lived SSE streams. + timeout=httpx2.Timeout(30, read=300), + ) -def create_httpx_client(headers=None, timeout=None, auth=None): - # AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL may be True, False, or an - # ssl.SSLContext (when a custom CA bundle path is configured). - # httpx's verify= accepts bool | str | ssl.SSLContext, so all three work. - ssl_setting = AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL - verify = ssl_setting if ssl_setting is not True else True - return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=verify) - - -def create_insecure_httpx_client(headers=None, timeout=None, auth=None): - return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False) - - -class OAuthTokenAuth(httpx.Auth): +class OAuthTokenAuth(httpx2.Auth): """Resolve current credentials per request and recover from concurrent token rotation.""" requires_request_body = True @@ -71,15 +50,15 @@ class OAuthTokenAuth(httpx.Auth): self.get_headers = get_headers async def async_auth_flow(self, request): - headers = httpx.Headers(await self.get_headers()) + headers = httpx2.Headers(await self.get_headers()) authorization = headers.get('Authorization') if not authorization: - raise httpx.RequestError('No OAuth access token available', request=request) + raise httpx2.RequestError('No OAuth access token available', request=request) request.headers.update(headers) response = yield request if response.status_code == 401: - headers = httpx.Headers(await self.get_headers()) + headers = httpx2.Headers(await self.get_headers()) if headers.get('Authorization') and headers['Authorization'] != authorization: request.headers.update(headers) yield request @@ -87,7 +66,7 @@ class OAuthTokenAuth(httpx.Auth): class MCPClient: def __init__(self, event_caller=None, server_name=''): - self.session: Optional[ClientSession] = None + self.client: Optional[Client] = None self.exit_stack = None self.event_caller = event_caller self.server_name = server_name @@ -96,7 +75,7 @@ class MCPClient: async def elicit(self, context, params): try: if isinstance(params, ElicitRequestFormParams): - Draft202012Validator.check_schema(params.requestedSchema) + Draft202012Validator.check_schema(params.requested_schema) else: url = urlsplit(params.url) if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password: @@ -106,7 +85,9 @@ class MCPClient: { 'type': 'request:elicitation', 'data': { - **params.model_dump(mode='json', include={'mode', 'message', 'requestedSchema', 'url'}), + **params.model_dump( + mode='json', by_alias=True, include={'mode', 'message', 'requested_schema', 'url'} + ), 'server_name': self.server_name, }, } @@ -119,51 +100,45 @@ class MCPClient: content = response.get('content') # Never fetch server-supplied schema references or coerce user answers. Draft202012Validator( - params.requestedSchema, format_checker=FormatChecker(), registry=Registry() + params.requested_schema, format_checker=FormatChecker(), registry=Registry() ).validate(content) return ElicitResult(action=response['action'], content=content) except Exception as e: log.warning('MCP elicitation failed: %s', type(e).__name__) return ElicitResult(action='cancel') - async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx.Auth] = None): + async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx2.Auth] = None): async with AsyncExitStack() as exit_stack: try: - self._streams_context = streamablehttp_client( - url, - headers=headers, - auth=auth, - httpx_client_factory=create_httpx_client - if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL - else create_insecure_httpx_client, + http_client = await exit_stack.enter_async_context(create_httpx_client(headers=headers, auth=auth)) + + # mode='auto' (the default) probes server/discover for the stateless + # 2026-07-28 protocol and falls back to the initialize handshake on + # legacy servers. The read timeout bounds that handshake and every + # later request that does not set its own; the SDK caps the probe itself. + self.client = await exit_stack.enter_async_context( + Client( + streamable_http_client(url, http_client=http_client), + read_timeout_seconds=MCP_INITIALIZE_TIMEOUT, + elicitation_callback=self.elicit if self.event_caller else None, + ) ) - - transport = await exit_stack.enter_async_context(self._streams_context) - read_stream, write_stream, _ = transport - - self._session_context = ClientSession( - read_stream, write_stream, elicitation_callback=self.elicit if self.event_caller else None - ) - - self.session = await exit_stack.enter_async_context(self._session_context) - with anyio.fail_after(MCP_INITIALIZE_TIMEOUT): - result = await self.session.initialize() - self.instructions = result.instructions + self.instructions = self.client.instructions self.exit_stack = exit_stack.pop_all() except Exception as e: await self.disconnect() raise e async def list_tool_specs(self) -> Optional[dict]: - if not self.session: + if not self.client: raise RuntimeError('MCP client is not connected.') tools = [] cursor = None while True: - result = await self.session.list_tools(cursor=cursor) + result = await self.client.list_tools(cursor=cursor) tools.extend(result.tools) - cursor = result.nextCursor + cursor = result.next_cursor if cursor is None: break @@ -172,32 +147,33 @@ class MCPClient: name = tool.name description = tool.description - inputSchema = tool.inputSchema + input_schema = tool.input_schema - # TODO: handle outputSchema if needed - outputSchema = getattr(tool, 'outputSchema', None) + # TODO: handle output_schema if needed - tool_specs.append({'name': name, 'description': description, 'parameters': inputSchema}) + tool_specs.append({'name': name, 'description': description, 'parameters': input_schema}) return tool_specs async def call_tool(self, function_name: str, function_args: dict) -> list[dict]: - if not self.session: + if not self.client: raise RuntimeError('MCP client is not connected.') - tool_call_timeout = None + # math.inf is the SDK's own "no timeout"; None would fall back to MCP_INITIALIZE_TIMEOUT. + tool_call_timeout = math.inf if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None and AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER > 0: - tool_call_timeout = timedelta(seconds=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) + tool_call_timeout = AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER - result = await self.session.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout) + result = await self.client.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout) if not result: raise Exception('No result returned from MCP tool call.') - result_dict = result.model_dump(mode='json') + result_dict = result.model_dump(mode='json', by_alias=True) result_content = result_dict['content'] + texts = [item['text'] for item in result_content if item['type'] == 'text'] - if result.isError: - raise Exception(result_content) + if result.is_error: + raise Exception('\n'.join(texts) or 'MCP tool call returned an error.') structured_content = result_dict.get('structuredContent') if structured_content is None: @@ -205,7 +181,6 @@ class MCPClient: # Compare serialized JSON: Python equality treats True and 1 as the same value. structured_json = JSONCodec.dumps(structured_content, sort_keys=True) - texts = [item['text'] for item in result_content if item['type'] == 'text'] text_jsons = [] for text in texts: try: @@ -219,6 +194,9 @@ class MCPClient: # FastMCP wraps scalar/list results while keeping their original text blocks. values = structured_content.get('result') + # The standalone fastmcp package sends the whole list as one text block. + if structured_content.keys() == {'result'} and text_jsons == [JSONCodec.dumps(values, sort_keys=True)]: + return result_content values = values if isinstance(values, list) else [values] if ( structured_content.keys() == {'result'} @@ -235,26 +213,26 @@ class MCPClient: return result_content async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]: - if not self.session: + if not self.client: raise RuntimeError('MCP client is not connected.') - result = await self.session.list_resources(cursor=cursor) + result = await self.client.list_resources(cursor=cursor) if not result: raise Exception('No result returned from MCP list_resources call.') - result_dict = result.model_dump() + result_dict = result.model_dump(by_alias=True) resources = result_dict.get('resources', []) return resources async def read_resource(self, uri: str) -> Optional[dict]: - if not self.session: + if not self.client: raise RuntimeError('MCP client is not connected.') - result = await self.session.read_resource(uri) + result = await self.client.read_resource(uri) if not result: raise Exception('No result returned from MCP read_resource call.') - result_dict = result.model_dump() + result_dict = result.model_dump(by_alias=True) return result_dict @@ -270,7 +248,7 @@ class MCPClient: # Prevent double-close from concurrent callers self.exit_stack = None - self.session = None + self.client = None try: # IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 9d88eb9a92..c706c5b3fb 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -86,8 +86,9 @@ from open_webui.utils.access_control import has_permission from open_webui.utils.access_control.files import get_owner_accessible_folder_files from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.ask_user import stage_ask_user_tool_calls +from open_webui.utils.audit import log_model_usage from open_webui.utils.chat import generate_chat_completion -from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.chat_id import is_saved_chat_id, is_temporary_chat_id from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.context_compaction import compact_messages_for_request from open_webui.utils.files import ( @@ -1112,9 +1113,10 @@ async def chat_completion_tools_handler( tool_type = tool.get('type', '') direct_tool = tool.get('direct', False) - spec = tool.get('spec', {}) - allowed_params = spec.get('parameters', {}).get('properties', {}).keys() - tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params} + if tool_type != 'mcp': + spec = tool.get('spec', {}) + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params} if tool.get('direct', False): tool_result = await event_caller( @@ -2109,6 +2111,13 @@ async def process_chat_payload(request, form_data, user, metadata, model): if metadata.get('tool_approval_resume') == 'tools' or pending_tool_calls(output): pending_assistant_message = assistant_message + # Guided regeneration: show the model the reply it is asked to revise. + regeneration_message_id = metadata.get('regeneration_message_id') + if regeneration_message_id: + regenerated_message = await Chats.get_message_by_id_and_message_id(chat_id, regeneration_message_id) + if regenerated_message and (regenerated_message.get('content') or regenerated_message.get('output')): + db_messages.append({k: v for k, v in regenerated_message.items() if k in MESSAGE_REPLAY_KEYS}) + system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = [system_message, *db_messages] if system_message else db_messages @@ -2137,7 +2146,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if regeneration_prompt: form_data['messages'].append({'role': 'user', 'content': regeneration_prompt}) - if is_saved_chat_id(chat_id) and user_message_id: + if (is_saved_chat_id(chat_id) or is_temporary_chat_id(chat_id)) and user_message_id: system_message = get_system_message(form_data.get('messages', [])) chat_system_prompt = get_content_from_message(system_message) if system_message else '' @@ -2453,7 +2462,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Otherwise, save any tools that filter inlets added for merging later. inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None) - # Selected and mentioned skills get bounded content; other skills remain available through view_skill. + # Mentions always load instructions; selected skills can optionally load through view_skill. chat_context = metadata.get('chat_context') or {} metadata['chat_context'] = chat_context skill_versions = chat_context.setdefault('skill_versions', {}) @@ -2462,6 +2471,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) activated_skill_ids = set(form_data.pop('skill_ids', None) or []) | mentioned_skill_ids skill_ids = sorted(activated_skill_ids | set(model.get('info', {}).get('meta', {}).get('skillIds', []))) + if payload_tools is None and await Config.get('skills.lazy_loading.enable', False): + activated_skill_ids = mentioned_skill_ids available_skills = [] terminal_skills = [] if is_note_chat: @@ -3088,8 +3099,10 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call): return params, f'Error: Tool "{name}" not found.', None, None, False tool_type = tool.get('type', '') direct_tool = tool.get('direct', False) - allowed_params = tool.get('spec', {}).get('parameters', {}).get('properties', {}).keys() - params = {key: value for key, value in params.items() if key in allowed_params} + # MCP servers validate their own arguments + if tool_type != 'mcp': + allowed_params = tool.get('spec', {}).get('parameters', {}).get('properties', {}).keys() + params = {key: value for key, value in params.items() if key in allowed_params} try: if ( @@ -3902,6 +3915,16 @@ async def non_streaming_chat_response_handler(response, ctx): if response_data is None: return response + usage = response_data.get('usage') or {} + timings = response_data.get('timings') or {} + if isinstance(usage, dict) and isinstance(timings, dict): + log_model_usage( + request, + user, + response_data.get('selected_model_id') or ctx['form_data']['model'], + {**usage, **timings}, + ) + chat_id = metadata.get('chat_id') or '' save_to_chat = is_saved_chat_id(chat_id) continuing = bool(metadata.get('assistant_message_id')) @@ -4135,7 +4158,6 @@ async def streaming_chat_response_handler(response, ctx): # and pyodide code interpreter. Server-side tools work without it. if event_emitter: task_id = str(uuid4()) # Create a unique task ID. - model_id = form_data.get('model', '') # Handle as a background task async def response_handler(response, events): @@ -4623,8 +4645,9 @@ async def streaming_chat_response_handler(response, ctx): }, ) - async def stream_body_handler(response, form_data): + async def stream_body_handler(response, current_form_data): nonlocal usage + nonlocal current_usage nonlocal finish_reason nonlocal output nonlocal prior_output @@ -4632,7 +4655,8 @@ async def streaming_chat_response_handler(response, ctx): nonlocal stream_size response_tool_calls = [] - response_usage = {} + current_usage = {} + form_data['model'] = current_form_data['model'] delta_count = 0 delta_chunk_size = max( @@ -4729,7 +4753,7 @@ async def streaming_chat_response_handler(response, ctx): ) stream_size = 0 - filter_extra_params = {'__body__': form_data, **extra_params} if filter_functions else None + filter_extra_params = {'__body__': current_form_data, **extra_params} if filter_functions else None async for line in response.body_iterator: line = line.decode('utf-8', 'replace') if isinstance(line, bytes) else line @@ -4785,13 +4809,13 @@ async def streaming_chat_response_handler(response, ctx): await event_emitter(data.get('event', {})) if 'selected_model_id' in data: - model_id = data['selected_model_id'] + form_data['model'] = data['selected_model_id'] if save_to_chat: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { - 'selectedModelId': model_id, + 'selectedModelId': form_data['model'], }, touch=False, ) @@ -4857,7 +4881,7 @@ async def streaming_chat_response_handler(response, ctx): # Normalize and capture usage for DB persistence if response_metadata.get('usage'): - response_usage = response_metadata['usage'] + current_usage = response_metadata['usage'] usage = merge_usage(usage, response_metadata['usage']) response_metadata['usage'] = usage @@ -4900,7 +4924,7 @@ async def streaming_chat_response_handler(response, ctx): raw_usage = data.get('usage', {}) or {} raw_usage.update(data.get('timings', {})) # llama.cpp if raw_usage: - response_usage = raw_usage + current_usage = raw_usage usage = merge_usage(usage, raw_usage) await event_emitter( { @@ -5543,12 +5567,13 @@ async def streaming_chat_response_handler(response, ctx): tool_calls.append(_split_tool_calls(responses_api_tool_calls)) await save_current_response_stream() - return normalize_usage(response_usage) + return normalize_usage(current_usage) output_start = len(prior_output) try: - response_usage = await stream_body_handler(response, form_data) + current_usage = await stream_body_handler(response, form_data) finally: + log_model_usage(request, user, form_data['model'], current_usage) if response.background: await response.background() @@ -5895,7 +5920,6 @@ async def streaming_chat_response_handler(response, ctx): try: new_form_data = { **form_data, - 'model': model_id, 'stream': True, 'metadata': metadata, } @@ -5929,8 +5953,8 @@ async def streaming_chat_response_handler(response, ctx): image_urls.append(part.get('image_url', '')) message['content'] = ''.join(text_parts) - if response_usage and tool_messages: - tool_messages[0]['usage'] = response_usage + if current_usage and tool_messages: + tool_messages[0]['usage'] = current_usage # Refresh citation context without restoring compacted history. user_prompt = get_last_user_message_item(form_data['messages']) @@ -5973,7 +5997,7 @@ async def streaming_chat_response_handler(response, ctx): metadata.get('base_user_prompt') or user_message, [message] ) messages, context_summary, compacted = await compact_messages_for_request( - request, user, messages, metadata, model_id, request.app.state.MODELS + request, user, messages, metadata, form_data['model'], request.app.state.MODELS ) finally: for message, content in user_messages: @@ -6062,7 +6086,10 @@ async def streaming_chat_response_handler(response, ctx): prior_output.pop() output = [] output_start = len(prior_output) - response_usage = await stream_body_handler(res, new_form_data) + try: + current_usage = await stream_body_handler(res, new_form_data) + finally: + log_model_usage(request, user, form_data['model'], current_usage) elif getattr(res, 'status_code', 200) >= 400: await emit_message_error(get_message_error_content(get_response_error_detail(res))) break @@ -6227,7 +6254,6 @@ async def streaming_chat_response_handler(response, ctx): try: new_form_data = { **form_data, - 'model': model_id, 'stream': True, 'metadata': metadata, 'messages': [ @@ -6261,7 +6287,10 @@ async def streaming_chat_response_handler(response, ctx): ) if isinstance(res, StreamingResponse): - await stream_body_handler(res, new_form_data) + try: + await stream_body_handler(res, new_form_data) + finally: + log_model_usage(request, user, form_data['model'], current_usage) elif getattr(res, 'status_code', 200) >= 400: await emit_message_error(get_message_error_content(get_response_error_detail(res))) break @@ -6386,6 +6415,8 @@ async def streaming_chat_response_handler(response, ctx): def wrap_item(item): return f'data: {item}\n\n' + audit_enabled = bool(getattr(request.state, 'audit_request_id', None)) + current_usage = None try: message = {} outlet_task = None @@ -6393,10 +6424,11 @@ async def streaming_chat_response_handler(response, ctx): outlet_enabled = ENABLE_API_OUTLET_FILTERS and bool(filter_functions) if ENABLE_API_OUTLET_FILTERS and not outlet_enabled: try: - model_id = model.get('id') if isinstance(model, dict) else model outlet_enabled = bool( (isinstance(model, dict) and 'pipeline' in model) - or get_sorted_filters(model_id, request.app.state.MODELS) + or get_sorted_filters( + model.get('id') if isinstance(model, dict) else model, request.app.state.MODELS + ) ) except Exception: outlet_enabled = True @@ -6415,7 +6447,7 @@ async def streaming_chat_response_handler(response, ctx): yield wrap_item(JSONCodec.dumps(event)) async for data in original_generator: - if filter_functions: + if filter_functions or audit_enabled: line = data.decode('utf-8', 'replace') if isinstance(data, bytes) else data if isinstance(line, str) and line.startswith('data:'): payload = line.removeprefix('data:').strip() @@ -6426,15 +6458,25 @@ async def streaming_chat_response_handler(response, ctx): event = None if isinstance(event, dict): - event, _ = await process_filter_functions( - request=request, - filter_context=filter_context, - filter_functions=filter_functions, - filter_type='stream', - form_data=event, - extra_params=extra_params, - ) - data = wrap_item(JSONCodec.dumps(event)) if event else None + if audit_enabled: + form_data['model'] = event.get('selected_model_id') or form_data['model'] + payload = ( + event.get('response') if isinstance(event.get('response'), dict) else event + ) + usage = payload.get('usage') or {} + timings = payload.get('timings') or {} + if isinstance(usage, dict) and isinstance(timings, dict) and (usage or timings): + current_usage = {**usage, **timings} + if filter_functions: + event, _ = await process_filter_functions( + request=request, + filter_context=filter_context, + filter_functions=filter_functions, + filter_type='stream', + form_data=event, + extra_params=extra_params, + ) + data = wrap_item(JSONCodec.dumps(event)) if event else None if data: if outlet_enabled: @@ -6455,6 +6497,8 @@ async def streaming_chat_response_handler(response, ctx): # Separate the error frame from any unfinished upstream event. yield f'\n\ndata: {JSONCodec.dumps({"error": {"message": "Chat completion stream failed"}})}\n\n' yield 'data: [DONE]\n\n' + finally: + log_model_usage(request, user, form_data['model'], current_usage) return StreamingResponse( stream_wrapper(response.body_iterator, events), diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 19ada49fc6..d2cd3c3840 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -34,7 +34,7 @@ BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base' def public_model_params(params): controls = params.get('model_controls', {}) - return ( + public = ( { 'model_controls': { key: { @@ -51,6 +51,9 @@ def public_model_params(params): if controls else {} ) + if 'compact_token_threshold' in params: + public['compact_token_threshold'] = params['compact_token_threshold'] + return public async def fetch_ollama_models(request: Request, user: UserModel = None): @@ -93,6 +96,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'evaluation.arena.enable', 'evaluation.arena.models', 'models.default_metadata', + 'models.default_params', ) if refresh: await openai.get_all_models.cache.clear() @@ -409,8 +413,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) action_items_by_id = {} filter_items_by_id = {} + default_threshold = (config.get('models.default_params') or {}).get('compact_token_threshold') for model in models: + if default_threshold is not None: + info = model.get('info') or {} + model['info'] = { + **info, + 'params': {'compact_token_threshold': default_threshold, **(info.get('params') or {})}, + } action_ids = [ action_id for action_id in set(model.pop('action_ids', [])) | global_action_ids diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index ee690088b6..7ab365828e 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -714,7 +714,11 @@ async def get_oauth_client_info_with_static_credentials( oauth_server_metadata.token_endpoint_auth_methods_supported and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported ): - token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0] + # List order is no preference; prefer a method that sends the secret + if 'client_secret_basic' in oauth_server_metadata.token_endpoint_auth_methods_supported: + token_endpoint_auth_method = 'client_secret_basic' + else: + token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0] oauth_client_info = OAuthClientInformationFull( client_id=oauth_client_id, diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 212d283129..f523b6ea0d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -451,6 +451,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr # Prepend tool ID to function name function_name = f'{tool_id}_{function_name}' + tool_dict['spec'] = {**tool_dict['spec'], 'name': function_name} tools_dict[function_name] = tool_dict else: if tool_id.startswith('server:'): @@ -551,6 +552,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr # Prepend server ID to function name function_name = f'{server_id}_{function_name}' + tool_dict['spec'] = {**tool_dict['spec'], 'name': function_name} tools_dict[function_name] = tool_dict else: @@ -1283,7 +1285,7 @@ def convert_openapi_to_tool_payload(openapi_spec): tool['parameters']['properties'].update(resolved_schema['properties']) if 'required' in resolved_schema: tool['parameters']['required'] = list( - set(tool['parameters']['required'] + resolved_schema['required']) + dict.fromkeys(tool['parameters']['required'] + resolved_schema['required']) ) elif resolved_schema.get('type') == 'array': tool['parameters'] = resolved_schema # special case for array @@ -1867,7 +1869,7 @@ async def execute_tool_server( final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe='')) if query_params: - final_url = f'{final_url}?{urlencode(query_params)}' + final_url = f'{final_url}?{urlencode(query_params, doseq=True)}' request_body_content = operation.get('requestBody', {}).get('content') if request_body_content and params: diff --git a/backend/requirements-slim.txt b/backend/requirements-slim.txt index e9c77f9368..e3c256c4da 100644 --- a/backend/requirements-slim.txt +++ b/backend/requirements-slim.txt @@ -4,14 +4,14 @@ numpy==2.4.6 typer==0.25.1 fastapi==0.136.3 -uvicorn[standard]==0.51.0 +uvicorn[standard]==0.54.0 pydantic==2.13.4 python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 orjson==3.11.9 -cryptography==48.0.0 +cryptography==48.0.1 pyotp==2.9.0 qrcode==8.2 bcrypt==5.0.0 @@ -23,13 +23,14 @@ joserfc==1.7.4 requests==2.34.2 regex==2026.5.9 google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches -aiohttp==3.14.3 # do not update to 3.13.3 - broken +aiohttp==3.14.5 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 aiofiles==25.1.0 starlette-compress==1.7.1 -Brotli==1.2.0 +Brotli==1.2.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 +httpx2==2.16.0 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 @@ -50,7 +51,7 @@ asgiref==3.11.1 # AI libraries tiktoken==0.13.0 -mcp==1.27.2 +mcp==2.3.0 openai==2.29.0 @@ -69,7 +70,7 @@ lxml==6.1.1 validators==0.35.0 psutil==7.2.2 -pillow==12.3.0 +pillow==13.0.0 rank-bm25==0.2.2 black==26.5.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index c5c95fa427..f4589de5bd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,12 +1,12 @@ fastapi==0.136.3 -uvicorn[standard]==0.51.0 +uvicorn[standard]==0.54.0 pydantic==2.13.4 python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 orjson==3.11.9 -cryptography==48.0.0 +cryptography==48.0.1 pyotp==2.9.0 qrcode==8.2 bcrypt==5.0.0 @@ -18,14 +18,15 @@ joserfc==1.7.4 requests==2.34.2 regex==2026.5.9 google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches -aiohttp==3.14.3 # do not update to 3.13.3 - broken +aiohttp==3.14.5 # do not update to 3.13.3 - broken aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER aiocache==0.12.3 aiofiles==25.1.0 starlette-compress==1.7.1 -Brotli==1.2.0 -brotlicffi==1.2.0.1 +Brotli==1.2.1 +brotlicffi==1.2.1.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 +httpx2==2.16.0 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 @@ -45,7 +46,7 @@ asgiref==3.11.1 # AI libraries tiktoken==0.13.0 -mcp==1.27.2 +mcp==2.3.0 openai==2.29.0 anthropic==0.86.0 @@ -67,12 +68,13 @@ einops==0.8.2 ftfy==6.3.1 chardet==7.4.3 -pypdf==6.7.5 +pypdf==6.21.2 docx2txt==0.9 python-docx==1.2.0 python-pptx==1.0.2 msoffcrypto-tool==6.0.0 unstructured==0.22.31 +preshed==3.0.13 # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader) Markdown==3.10.2 beautifulsoup4==4.14.3 @@ -87,7 +89,7 @@ psutil==7.2.2 sentencepiece==0.2.1 soundfile==0.13.1 -pillow==12.3.0 +pillow==13.0.0 opencv-python-headless==4.13.0.92 rapidocr==3.10.0 rank-bm25==0.2.2 diff --git a/backend/start.sh b/backend/start.sh index ef7cb5af10..adf521811b 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -60,7 +60,7 @@ fi if [[ "${USE_CUDA_DOCKER,,}" == "true" ]]; then echo "CUDA enabled — extending LD_LIBRARY_PATH for torch/cudnn libraries." - export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}:/usr/local/lib/python3.11/site-packages/torch/lib:/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib" + export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}:/usr/local/lib/python3.12/site-packages/torch/lib:/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib" fi # ── HuggingFace Space deployment ───────────────────────────────────────────── diff --git a/demo.png b/demo.png deleted file mode 100644 index a38ddaf6ab..0000000000 Binary files a/demo.png and /dev/null differ diff --git a/docker-compose-launcher.sh b/docker-compose-launcher.sh index 8305a10346..026de313fa 100755 --- a/docker-compose-launcher.sh +++ b/docker-compose-launcher.sh @@ -60,9 +60,9 @@ Options: Examples: $(basename "$0") --drop - $(basename "$0") --enable-gpu[count=1] - $(basename "$0") --enable-gpu[count=all] --webui[port=8080] - $(basename "$0") --enable-gpu[count=1] --enable-api[port=12345] --data[folder=./data] --build + $(basename "$0") '--enable-gpu[count=1]' + $(basename "$0") '--enable-gpu[count=all]' '--webui[port=8080]' + $(basename "$0") '--enable-gpu[count=1]' '--enable-api[port=12345]' '--data[folder=./data]' --build EOF } diff --git a/package-lock.json b/package-lock.json index efd8c0656e..2f019650ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.12.3", + "version": "0.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.12.3", + "version": "0.12.4", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -56,7 +56,7 @@ "crc-32": "^1.2.2", "dayjs": "^1.11.10", "docx-preview": "0.4.0", - "dompurify": "^3.2.6", + "dompurify": "^3.4.17", "eventsource-parser": "^1.1.2", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", @@ -1241,9 +1241,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1367,9 +1367,9 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3131,12 +3131,6 @@ "type-checked-collections": "^0.1.7" } }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.8", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", @@ -3729,9 +3723,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.68.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz", - "integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4105,29 +4099,29 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", "dev": true, "license": "MIT", "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "node_modules/@tiptap/core": { - "version": "3.20.2", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.2.tgz", - "integrity": "sha512-zKW4LqZt+aNdvz9o4R0/j+D+gfhwzuFItwh7wbqz8g8bWi0jaV95VybeVFVKeg/KGTc3sAa4mm+hGgvgrY+Gvg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz", + "integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.20.2" + "@tiptap/pm": "3.32.0" } }, "node_modules/@tiptap/extension-blockquote": { @@ -4636,29 +4630,24 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.20.2", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.2.tgz", - "integrity": "sha512-tEMZlLy/6ms41PQtyjmniZ3tTd8elavd8htjYzHLPSHtz11zaYV9YjnmnwHK8gynhcSES1orO9z1U4nT4ZLpqg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz", + "integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==", "license": "MIT", "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.5.0", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.38.1" + "prosemirror-changeset": "^2.4.4", + "prosemirror-commands": "^1.7.2", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.1", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.12", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.2", + "prosemirror-view": "^1.42.6" }, "funding": { "type": "github", @@ -5578,9 +5567,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6152,16 +6141,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -7814,9 +7803,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/devlop": { @@ -7915,9 +7904,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8348,9 +8337,9 @@ } }, "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8399,9 +8388,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -9227,9 +9216,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9802,9 +9791,9 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "devOptional": true, "license": "MIT" }, @@ -10149,9 +10138,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -10697,9 +10686,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -10997,9 +10986,9 @@ "license": "BSD-3-Clause" }, "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -11013,8 +11002,8 @@ "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -11069,9 +11058,9 @@ "license": "MIT" }, "node_modules/matcher-collection/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -11435,6 +11424,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -12010,9 +12017,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -12029,9 +12036,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12145,24 +12152,6 @@ "node": ">=4" } }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12278,9 +12267,9 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", - "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" @@ -12296,9 +12285,9 @@ } }, "node_modules/prosemirror-commands": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", - "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -12307,9 +12296,9 @@ } }, "node_modules/prosemirror-dropcursor": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", - "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -12347,9 +12336,9 @@ } }, "node_modules/prosemirror-history": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", - "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.2.2", @@ -12402,9 +12391,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -12454,45 +12443,30 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, "node_modules/prosemirror-transform": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", - "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz", + "integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.6", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", - "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", + "version": "1.42.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz", + "integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==", "license": "MIT", "dependencies": { - "prosemirror-model": "^1.20.0", + "prosemirror-model": "^1.25.12", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -12624,9 +12598,9 @@ "license": "MIT" }, "node_modules/quick-temp/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -12906,9 +12880,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -13778,9 +13752,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -13813,9 +13787,9 @@ "license": "MIT" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -14306,9 +14280,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -14728,9 +14702,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -16279,9 +16253,9 @@ "license": "MIT" }, "node_modules/walk-sync/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index cce9df0b48..a9d68a1730 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.12.3", + "version": "0.12.4", "private": true, "scripts": { "crypto:verify": "node scripts/encryption/artifact.mjs", @@ -10,7 +10,7 @@ "prebuild:watch": "npm run crypto:verify", "dev": "npm run pyodide:fetch && vite dev --host", "dev:5050": "npm run pyodide:fetch && vite dev --port 5050", - "build": "npm run pyodide:fetch && vite build", + "build": "npm run pyodide:fetch && node --max-old-space-size=12288 node_modules/vite/bin/vite.js build", "build:watch": "npm run pyodide:fetch && vite build --watch", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -21,7 +21,8 @@ "lint:backend": "pylint backend/", "format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"", "format:backend": "ruff format . --exclude .venv --exclude venv", - "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"", + "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\" && npm run i18n:check", + "i18n:check": "node scripts/i18n/check.mjs --fix", "cy:open": "cypress open", "test:frontend": "vitest --passWithNoTests", "pyodide:fetch": "node scripts/prepare-pyodide.js" @@ -106,7 +107,7 @@ "crc-32": "^1.2.2", "dayjs": "^1.11.10", "docx-preview": "0.4.0", - "dompurify": "^3.2.6", + "dompurify": "^3.4.17", "eventsource-parser": "^1.1.2", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", diff --git a/pyproject.toml b/pyproject.toml index 78f256eff8..737226fb10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,14 @@ authors = [ license = { file = "LICENSE" } dependencies = [ "fastapi==0.136.3", - "uvicorn[standard]==0.51.0", + "uvicorn[standard]==0.54.0", "pydantic==2.13.4", "python-multipart==0.0.32", "itsdangerous==2.2.0", "python-socketio==5.16.2", "orjson==3.11.9", - "cryptography==48.0.0", + "cryptography==48.0.1", "pyotp==2.9.0", "qrcode==8.2", "bcrypt==5.0.0", @@ -24,14 +24,15 @@ dependencies = [ "joserfc==1.7.4", "requests==2.34.2", - "aiohttp==3.14.3", # do not update to 3.13.3 - broken + "aiohttp==3.14.5", # do not update to 3.13.3 - broken "aiodns==3.6.1", # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER "aiocache==0.12.3", "aiofiles==25.1.0", "starlette-compress==1.7.1", - "Brotli==1.2.0", - "brotlicffi==1.2.0.1", + "Brotli==1.2.1", + "brotlicffi==1.2.1.0", "httpx[socks,http2,zstd,cli,brotli]==0.28.1", + "httpx2==2.16.0", "starsessions[redis]==2.2.1", "python-mimeparse==2.0.0", @@ -53,7 +54,7 @@ dependencies = [ "tiktoken==0.13.0", "regex==2026.5.9", "google-re2==1.1.20251105", - "mcp==1.27.2", + "mcp==2.3.0", "openai==2.29.0", "anthropic==0.86.0", @@ -76,7 +77,7 @@ dependencies = [ "ftfy==6.3.1", "chardet==7.4.3", - "pypdf==6.7.5", + "pypdf==6.21.2", "docx2txt==0.9", "python-docx==1.2.0", "python-pptx==1.0.2", @@ -95,7 +96,7 @@ dependencies = [ "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==12.3.0", + "pillow==13.0.0", "opencv-python-headless==4.13.0.92", "rapidocr==3.10.0", "rank-bm25==0.2.2", @@ -140,6 +141,7 @@ mariadb = [ ] unstructured = [ "unstructured==0.22.31", + "preshed==3.0.13", # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader) ] all = [ @@ -158,6 +160,7 @@ all = [ "azure-search-documents==12.0.0", "unstructured==0.22.31", + "preshed==3.0.13", # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader) ] [project.scripts] diff --git a/scripts/encryption/artifact.mjs b/scripts/encryption/artifact.mjs index d90c339e1c..a212ad5772 100644 --- a/scripts/encryption/artifact.mjs +++ b/scripts/encryption/artifact.mjs @@ -1,8 +1,9 @@ // Normal builds only read committed files. No Rust or network required. import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; -const root = new URL('../../', import.meta.url).pathname; +import { join, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); const source = 'crypto/encryption'; const artifact = 'src/lib/encryption/vendor'; const hash = (data) => createHash('sha256').update(data).digest('hex'); @@ -10,7 +11,7 @@ function files(dir) { return readdirSync(join(root, dir), { withFileTypes: true }) .flatMap((e) => { if (e.name === 'target' || e.name.startsWith('.')) return []; - const path = join(dir, e.name); + const path = posix.join(dir, e.name); return e.isDirectory() ? files(path) : [path]; }) .sort(); @@ -21,7 +22,9 @@ const inputs = [ 'scripts/encryption/artifact.mjs' ].sort(); const sourceFingerprint = hash( - inputs.map((p) => `${p}\0${hash(readFileSync(join(root, p)))}`).join('\n') + inputs + .map((p) => `${p}\0${hash(readFileSync(join(root, p), 'utf8').replaceAll('\r\n', '\n'))}`) + .join('\n') ); const manifestPath = join(root, artifact, 'manifest.json'); if (process.argv.includes('--write')) { @@ -31,7 +34,7 @@ if (process.argv.includes('--write')) { const checksums = Object.fromEntries( files(artifact) .filter((p) => !p.endsWith('/manifest.json')) - .map((p) => [relative(artifact, p), hash(readFileSync(join(root, p)))]) + .map((p) => [posix.relative(artifact, p), hash(readFileSync(join(root, p)))]) ); writeFileSync( manifestPath, @@ -59,7 +62,7 @@ if (process.argv.includes('--write')) { throw Error('Encryption source changed: rebuild and review the artifact.'); const actual = files(artifact) .filter((p) => !p.endsWith('/manifest.json')) - .map((p) => relative(artifact, p)) + .map((p) => posix.relative(artifact, p)) .sort(); if (JSON.stringify(actual) !== JSON.stringify(Object.keys(manifest.checksums).sort())) throw Error('Unexpected crypto artifact files'); diff --git a/scripts/generate-sbom.sh b/scripts/generate-sbom.sh index dd91087eba..cffcb08d69 100755 --- a/scripts/generate-sbom.sh +++ b/scripts/generate-sbom.sh @@ -63,7 +63,7 @@ generate() { # --- Python: resolve all transitive deps without installing --- dim "Resolving Python transitive deps (uv pip compile)..." uv pip compile "$ROOT_DIR/backend/requirements.txt" \ - --python-version 3.11 \ + --python-version 3.12 \ --quiet \ > "$WORK_DIR/requirements-resolved.txt" 2>/dev/null diff --git a/scripts/i18n/check.mjs b/scripts/i18n/check.mjs new file mode 100644 index 0000000000..a1dcece4c2 --- /dev/null +++ b/scripts/i18n/check.mjs @@ -0,0 +1,126 @@ +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const pluralSuffix = /_(zero|one|two|few|many|other)$/; + +/** @param {string} text */ +function interpolation(text) { + const names = new Set(); + const literal = text.replace(/\{\{([^{}]+)\}\}/g, (_, expression) => { + names.add(expression.replace(/^-/, '').split(',')[0].trim()); + return ''; + }); + return { names, literal }; +} + +/** + * @param {string} key + * @param {unknown} value + * @param {string} source + */ +export function validateTranslation(key, value, source = key) { + if (typeof value !== 'string') return ['translation must be a string']; + if (!value.trim()) return []; + + const expected = interpolation(source); + const actual = interpolation(value); + const problems = []; + for (const name of expected.names) { + // Plural forms can express the count in words (e.g. "one file"). + if (name === 'count' && pluralSuffix.test(key)) continue; + if (!actual.names.has(name)) problems.push(`missing {{${name}}}`); + } + for (const name of actual.names) { + if (!expected.names.has(name)) problems.push(`unexpected {{${name}}}`); + } + + // Literal JSON is allowed, but translations must not introduce extra braces. + for (const brace of ['{', '}']) { + if (actual.literal.split(brace).length > expected.literal.split(brace).length) { + problems.push('unexpected braces outside {{placeholders}}'); + break; + } + } + return problems; +} + +/** @param {string} key @param {unknown} value @param {string} source */ +export function repairTranslation(key, value, source = key) { + if (typeof value !== 'string' || !validateTranslation(key, value, source).length) return value; + let repaired = value; + const expected = interpolation(source); + for (const name of expected.names) { + // Recover placeholders accidentally written with only one pair of braces. + if (!interpolation(repaired).names.has(name)) { + repaired = repaired.replaceAll(`{${name}}`, `{{${name}}}`); + } + } + const missing = [...expected.names].filter((name) => !interpolation(repaired).names.has(name)); + if ( + missing.length === 1 && + !source.includes('[') && + (repaired.match(/\[[^\[\]]+\]/g) ?? []).length === 1 + ) { + repaired = repaired.replace(/\[[^\[\]]+\]/, () => `{{${missing[0]}}}`); + } + // Unwrap escaped literal examples without changing their translated surroundings. + for (const [literal] of source.matchAll(/\{[^{}]+\}/g)) { + repaired = repaired.replaceAll("{{'{" + literal + "}'}}", literal); + } + for (const [url] of source.matchAll(/https?:\/\/[^\s)]+/g)) { + repaired = repaired.replaceAll(`{${url}}`, url); + } + return validateTranslation(key, repaired, source).length ? value : repaired; +} + +/** @param {string} directory @param {boolean} fix */ +export function checkCatalogs(directory, fix = false) { + /** @param {string} locale @returns {Record} */ + const readCatalog = (locale) => + JSON.parse(readFileSync(join(directory, locale, 'translation.json'), 'utf8')); + const english = readCatalog('en-US'); + const problems = []; + let fixed = 0; + for (const locale of readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort()) { + const catalog = readCatalog(locale); + let changed = false; + for (const [key, value] of Object.entries(catalog)) { + const baseKey = key.replace(pluralSuffix, ''); + const source = english[key] || english[baseKey] || english[`${baseKey}_other`] || key; + const repaired = fix ? repairTranslation(key, value, source) : value; + if (repaired !== value) { + catalog[key] = repaired; + changed = true; + fixed++; + } + const issues = validateTranslation(key, repaired, source); + if (issues.length) { + problems.push(`${locale}/translation.json: ${JSON.stringify(key)}: ${issues.join('; ')}`); + } + } + if (changed) { + writeFileSync( + join(directory, locale, 'translation.json'), + JSON.stringify(catalog, null, '\t') + '\n' + ); + } + } + return { problems, fixed }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const fix = process.argv.includes('--fix'); + const { problems, fixed } = checkCatalogs(resolve('src/lib/i18n/locales'), fix); + if (fixed) console.log(`i18n: repaired ${fixed} translation(s).`); + if (problems.length) { + console.error(problems.join('\n')); + console.error(`i18n: ${problems.length} invalid translation(s). Fix the entries above.`); + process.exitCode = 1; + } else { + console.log('i18n: translation placeholders verified.'); + } +} diff --git a/src/app.css b/src/app.css index d5a7f782ae..07e9631d16 100644 --- a/src/app.css +++ b/src/app.css @@ -448,6 +448,13 @@ html.high-contrast :is(input, textarea, [contenteditable='true'], .ProseMirror): outline-offset: -2px; } +html [data-sonner-toaster][data-theme='light'] { + --success-text: hsl(140, 100%, 25%); + --info-text: hsl(210, 92%, 43%); + --warning-text: hsl(31, 92%, 35%); + --error-text: hsl(360, 100%, 43%); +} + .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -956,11 +963,11 @@ body { } } -html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) { +html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table, .cm-placeholder) { color: var(--color-gray-700); } -html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) { +html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table, .cm-placeholder) { color: var(--color-gray-300); } diff --git a/src/app.html b/src/app.html index 667808e011..28a7135a1b 100644 --- a/src/app.html +++ b/src/app.html @@ -113,6 +113,7 @@ // Do not alter, remove, obscure, or replace it except as LICENSE permits: // https://docs.openwebui.com/license. logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png'; + logo.alt = ''; document.addEventListener('DOMContentLoaded', function () { const splash = document.getElementById('splash-screen'); @@ -162,6 +163,7 @@ https://docs.openwebui.com/license. --> { let error = null; @@ -52,6 +53,7 @@ export const getUserAnalytics = async ( if (endDate) searchParams.append('end_date', endDate.toString()); if (limit) searchParams.append('limit', limit.toString()); if (groupId) searchParams.append('group_id', groupId); + searchParams.append('metric', metric); const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, { method: 'GET', @@ -166,8 +168,10 @@ export const getDailyStats = async ( token: string = '', startDate: number | null = null, endDate: number | null = null, - granularity: 'hourly' | 'daily' = 'daily', - groupId: string | null = null + granularity: '10m' | 'hourly' | '3h' | '6h' | 'daily' = 'daily', + groupId: string | null = null, + metric: 'messages' | 'tokens' = 'messages', + compare = false ) => { let error = null; @@ -175,6 +179,8 @@ export const getDailyStats = async ( if (startDate) searchParams.append('start_date', startDate.toString()); if (endDate) searchParams.append('end_date', endDate.toString()); searchParams.append('granularity', granularity); + searchParams.append('metric', metric); + if (compare) searchParams.append('compare', 'true'); if (groupId) searchParams.append('group_id', groupId); const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { diff --git a/src/lib/apis/calendar/index.ts b/src/lib/apis/calendar/index.ts index 8be3b9f52b..9cf979cb07 100644 --- a/src/lib/apis/calendar/index.ts +++ b/src/lib/apis/calendar/index.ts @@ -131,7 +131,7 @@ export const createCalendar = async (token: string, form: CalendarForm): Promise export const updateCalendar = async ( token: string, calendarId: string, - form: Partial + form: Partial> ): Promise => { let error = null; @@ -142,7 +142,7 @@ export const updateCalendar = async ( 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify(form) + body: JSON.stringify({ ...form, access_grants: undefined }) }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -456,3 +456,36 @@ export const searchCalendarEvents = async ( return res; }; + +export const updateCalendarAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index a3dd5e840b..ccb84fe201 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -333,7 +333,7 @@ export const removeMembersById = async ( export const updateChannelById = async ( token: string = '', channel_id: string, - channel: ChannelForm + channel: Omit ) => { let error = null; @@ -344,7 +344,7 @@ export const updateChannelById = async ( 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify({ ...channel }) + body: JSON.stringify({ ...channel, access_grants: undefined }) }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -1043,3 +1043,32 @@ export const removeAppFromChannel = async ( return res; }; + +export const updateChannelAccessGrants = async (token: string, id: string, accessGrants: any[]) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 606d87fc9b..3218e455e6 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1315,6 +1315,23 @@ export const updateChatById = async ( return res; }; +export const updateChatParams = async ( + token: string, + id: string, + params: Record +) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/params`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify(params) + }); + if (!res.ok) throw getErrorDetail(await res.json()); + return res.json(); +}; + export const compactChatById = async (token: string, id: string, model?: string | null) => { let error = null; diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 236664f115..b50d547fde 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -1,5 +1,22 @@ import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import type { Banner } from '$lib/types'; +import type { Visibility } from '$lib/utils/ui-visibility'; + +export const setInterfaceVisibility = async ( + token: string, + visibility: Visibility +): Promise => { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/interface/visibility`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ visibility }) + }); + if (!res.ok) throw (await res.json()).detail; + return (await res.json()).visibility; +}; export const importConfig = async (token: string, config: object) => { let error = null; diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts index 6574627f89..d2651e030f 100644 --- a/src/lib/apis/files/index.ts +++ b/src/lib/apis/files/index.ts @@ -6,7 +6,8 @@ export const uploadFile = async ( file: File, metadata?: object | null, process?: boolean | null, - stream: boolean = true + stream: boolean = true, + onUploaded?: (uploadedFile: { id: string }) => void ) => { const data = new FormData(); data.append('file', file); @@ -43,6 +44,10 @@ export const uploadFile = async ( throw error; } + if (res) { + onUploaded?.(res); + } + if (res && stream) { const status = await getFileProcessStatus(token, res.id); @@ -179,73 +184,56 @@ export const getFiles = async (token: string = '', content: boolean = false) => return res; }; -export const searchFiles = async ( +const searchFilesByPath = async ( + path: string, + token: string, + filename: string, + skip: number, + limit: number, + content: boolean +) => { + const searchParams = new URLSearchParams({ + filename, + skip: String(skip), + limit: String(limit), + content: String(content) + }); + const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}?${searchParams}`, { + headers: { Accept: 'application/json', authorization: `Bearer ${token}` } + }); + if (!res.ok) { + const error = await res.json(); + throw error.detail ?? error; + } + return res.json(); +}; + +export const searchFiles = ( token: string, filename: string = '*', skip: number = 0, limit: number = 50, content: boolean = false -) => { - let error = null; +) => searchFilesByPath('search', token, filename, skip, limit, content); - const searchParams = new URLSearchParams(); - searchParams.append('filename', filename); - searchParams.append('skip', String(skip)); - searchParams.append('limit', String(limit)); - searchParams.append('content', String(content)); +export const searchUserFiles = ( + token: string, + filename: string = '*', + skip: number = 0, + limit: number = 50, + content: boolean = false +) => searchFilesByPath('user/search', token, filename, skip, limit, content); - const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err.detail; - console.error(err); - return []; - }); - - if (error) { - throw error; - } - - return res; +const getFileCountByPath = async (path: string, token: string) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}`, { + headers: { Accept: 'application/json', authorization: `Bearer ${token}` } + }); + if (!res.ok) throw await res.json(); + return res.json(); }; -export const getFileCount = async (token: string = '') => { - let error = null; - - const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - error = err; - console.error(err); - return null; - }); - - if (error) { - throw error; - } - - return res; -}; +export const getFileCount = (token: string = '') => getFileCountByPath('count', token); +export const getUserFileCount = (token: string = '') => getFileCountByPath('user/count', token); export const getFileById = async (token: string, id: string) => { let error = null; @@ -388,7 +376,7 @@ export const deleteFileById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err.detail; + error = err.detail ?? err; console.error(err); return null; }); diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index e2ac269c00..d3a1780e9a 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -288,7 +288,7 @@ export const updateExternalKnowledgeSource = async (token: string, id: string, p 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify(payload) + body: JSON.stringify({ ...payload, access_grants: undefined }) }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -608,7 +608,6 @@ type KnowledgeUpdateForm = { name?: string; description?: string; data?: object; - access_grants?: object[]; }; export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => { @@ -624,8 +623,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl body: JSON.stringify({ name: form?.name ? form.name : undefined, description: form?.description ? form.description : undefined, - data: form?.data ? form.data : undefined, - access_grants: form.access_grants + data: form?.data ? form.data : undefined }) }) .then(async (res) => { diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 440fbf8d7c..a80cc010ea 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -326,15 +326,13 @@ export const toggleModelById = async (token: string, id: string) => { export const updateModelById = async (token: string, id: string, model: object) => { let error = null; - const { base_model_id, name, meta, params, access_grants, is_active, commit_message } = - model as any; + const { base_model_id, name, meta, params, is_active, commit_message } = model as any; const payload = { id, base_model_id, name, meta, params, - access_grants, is_active, commit_message }; diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index e03d935f9c..71626953f3 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -315,7 +315,11 @@ export const createNoteChatById = async (token: string, id: string) => { return res; }; -export const updateNoteById = async (token: string, id: string, note: NoteItem) => { +export const updateNoteById = async ( + token: string, + id: string, + note: Omit +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, { @@ -326,7 +330,8 @@ export const updateNoteById = async (token: string, id: string, note: NoteItem) authorization: `Bearer ${token}` }, body: JSON.stringify({ - ...note + ...note, + access_grants: undefined }) }) .then(async (res) => { diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index bd75c59684..9f1ae87004 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -262,7 +262,10 @@ export const getPromptById = async (token: string, promptId: string) => { return res; }; -export const updatePromptById = async (token: string, prompt: PromptItem) => { +export const updatePromptById = async ( + token: string, + prompt: Omit +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${prompt.id}/update`, { @@ -272,7 +275,7 @@ export const updatePromptById = async (token: string, prompt: PromptItem) => { 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify(prompt) + body: JSON.stringify({ ...prompt, access_grants: undefined }) }) .then(async (res) => { if (!res.ok) throw await res.json(); diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index 7fbad04cef..05c0f00f63 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -217,7 +217,8 @@ export const updateSkillById = async (token: string, id: string, skill: object) authorization: `Bearer ${token}` }, body: JSON.stringify({ - ...skill + ...skill, + access_grants: undefined }) }) .then(async (res) => { diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index b3cdbcb876..e3fc9299b9 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -214,7 +214,8 @@ export const updateToolById = async (token: string, id: string, tool: object) => authorization: `Bearer ${token}` }, body: JSON.stringify({ - ...tool + ...tool, + access_grants: undefined }) }) .then(async (res) => { diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 254abb06ac..7eff67757d 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -198,7 +198,7 @@ if (direct) { const res = await getToolServerData( - auth_type === 'bearer' ? key : localStorage.token, + auth_type === 'bearer' ? key : auth_type === 'session' ? localStorage.token : null, path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}` ).catch((err) => { toast.error($i18n.t('Connection failed')); diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte index 3e7101dbce..c401e71ab9 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -114,7 +114,7 @@ - favicon +
diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index c761dc1933..e31579197f 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -1,5 +1,6 @@ - { - if (event.key === 'Escape') tokenTooltip?.hide(); - }} -/> - -
-

+
+

{$i18n.t('Analytics')}

{#if groups.length > 0} @@ -285,12 +357,14 @@ {/if} + {#if selectedPeriod === '24h'}{/if} + {#if ['24h', '7d', '30d'].includes(selectedPeriod)}{/if} + {#if selectedPeriod === '30d'} + + + {/if} + +
-
- {/if} + {#if dailyStats.length && (summary.total_messages > 0 || comparing)} + + {:else}
+ {$i18n.t('No activity in this period')} +
{/if} + {/if} -{#if loading} +{#if !validRange} +
+ {$i18n.t('Choose a valid start and end date.')} +
+{:else if loadError} + +{:else if loading}
{:else} -
-
- {#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab} - - {/each} +
+
+
+ {#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab} + + {/each} +
-
-
- {#if selectedLeaderboard === 'models'} - - -
-
- - - - - - - - - - {#each tokenColumns as column} +
+ {#if selectedLeaderboard === 'models'} + + +
+
+
# toggleModelSort('name')} - > -
- {$i18n.t('Model')} - {#if modelOrderBy === 'name'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('count')} - > -
- {$i18n.t('Messages')} - {#if modelOrderBy === 'count'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('users')} - > -
- {$i18n.t('Users')} - {#if modelOrderBy === 'users'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('chats')} - > -
- {$i18n.t('Chats')} - {#if modelOrderBy === 'chats'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
+ + + - {/each} - - - - - {#each sortedModels as model, idx (model.model_id)} - { - selectedModel = { id: model.model_id, name: model.name }; - showModelModal = true; - }} - > - - - - - - {#each tokenColumns as column} - - {/each} - - - {/each} - {#if sortedModels.length === 0} - - {/if} - -
# toggleModelSort(column.key)} + class="font-normal px-2.5 py-1.5 cursor-pointer select-none" + on:click={() => toggleModelSort('name')} > -
- {column.label} - {#if modelOrderBy === column.key} +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} {#if modelDirection === 'asc'}
toggleModelSort('percentage')} - > -
- % - {#if modelOrderBy === 'percentage'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
{idx + 1} -
- {model.name} { - // LICENSE covers this Open WebUI fallback logo. - // Do not alter, remove, obscure, or replace it except as LICENSE permits: - // https://docs.openwebui.com/license. - e.target.src = '/favicon.png'; - }} - /> - {model.name} -
-
{model.count.toLocaleString()}{(model.unique_users ?? 0).toLocaleString()}{(model.unique_chats ?? 0).toLocaleString()}{formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)} - {totalModelMessages > 0 - ? ((model.count / totalModelMessages) * 100).toFixed(1) - : 0}% -
{$i18n.t('No data')}
-
-
- {:else} - - -
-
- - - - - - - - {#each tokenColumns as column} + + + + {#each tokenColumns as column} + + {/each} + + + + + {#each sortedModels as model, idx (model.model_id)} + {@const value = + selectedMetric === 'tokens' + ? (tokenStats[model.model_id]?.total_tokens ?? 0) + : model.count} + { + selectedModel = { id: model.model_id, name: model.name }; + showModelModal = true; + }} + > + + + + + + {#each tokenColumns as column} + + {/each} + + + {/each} + {#if sortedModels.length === 0} + + {/if} + +
# toggleUserSort('name')} - > -
- {$i18n.t('User')} - {#if userOrderBy === 'name'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort('count')} - > -
- {$i18n.t('Messages')} - {#if userOrderBy === 'count'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort(column.key)} + class="font-normal px-2.5 py-1.5 cursor-pointer select-none text-right" + on:click={() => toggleModelSort('count')} >
- {column.label} - {#if userOrderBy === column.key} + {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('users')} + > +
+ {$i18n.t('Users')} + {#if modelOrderBy === 'users'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('chats')} + > +
+ {$i18n.t('Chats')} + {#if modelOrderBy === 'chats'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort(column.key)} + > +
+ {column.label} + {#if modelOrderBy === column.key} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('percentage')} + > +
+ {selectedMetric === 'tokens' + ? $i18n.t('Token share') + : $i18n.t('Message share')} + {#if modelOrderBy === 'percentage'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{idx + 1} +
+ {model.name} { + // LICENSE covers this Open WebUI fallback logo. + // Do not alter, remove, obscure, or replace it except as LICENSE permits: + // https://docs.openwebui.com/license. + const image = e.currentTarget as HTMLImageElement; + if (!image.src.endsWith('/favicon.png')) image.src = '/favicon.png'; + }} + /> + {model.name} +
+
{model.count.toLocaleString()}{(model.unique_users ?? 0).toLocaleString()}{(model.unique_chats ?? 0).toLocaleString()}{formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)} +
+ + {usageShare(value, metricTotal)} +
+
{$i18n.t('No data')}
+
+
+ {:else} + + +
+
+ + + + + - {/each} - - - - {#each sortedUsers as user, idx (user.user_id)} - - - + + {#each tokenColumns as column} - + {/each} + - {/each} - {#if sortedUsers.length === 0} - - {/if} - -
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} {#if userDirection === 'asc'}
{idx + 1} -
- {user.name { - e.target.src = '/user.png'; - }} - /> - {user.name || user.email || user.user_id.substring(0, 8)} +
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if}
- -
{user.count.toLocaleString()}{formatNumber(user[column.key] ?? 0)} toggleUserSort(column.key)} + > +
+ {column.label} + {#if userOrderBy === column.key} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('percentage')} + > +
+ {selectedMetric === 'tokens' + ? $i18n.t('Token share') + : $i18n.t('Message share')} + {#if userOrderBy === 'percentage'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('No data')}
-
-
- {/if} -
+ + + {#each sortedUsers as user, idx (user.user_id)} + {@const value = + selectedMetric === 'tokens' ? (user.total_tokens ?? 0) : user.count} + + {idx + 1} + +
+ + {user.name { + const image = e.currentTarget as HTMLImageElement; + if (!image.src.endsWith('/user.png')) image.src = '/user.png'; + }} + /> + + {user.name || user.email || user.user_id.substring(0, 8)} +
+ + {user.count.toLocaleString()} + {#each tokenColumns as column} + {formatNumber(user[column.key] ?? 0)} + {/each} -
- ⓘ {$i18n.t('Message counts are based on assistant responses.')} + +
+ + {usageShare(value, metricTotal)} +
+ + + {/each} + {#if sortedUsers.length === 0} + {$i18n.t('No data')} + {/if} + + +
+
+ {/if} +
+

+
+ {$i18n.t('Message counts are based on assistant responses.')} + {$i18n.t('Token counts are estimates and may not reflect actual API usage')}
{/if} + + diff --git a/src/lib/components/admin/Analytics/UsageChart.svelte b/src/lib/components/admin/Analytics/UsageChart.svelte new file mode 100644 index 0000000000..f7bade1efb --- /dev/null +++ b/src/lib/components/admin/Analytics/UsageChart.svelte @@ -0,0 +1,759 @@ + + + + + { + if (event.key === 'Escape' && tooltip?.state.isVisible) { + event.stopPropagation(); + hideTooltip(); + } + }} +/> + +
+
+ {hovered + ? dateLabel(hovered.date) + : selectedModel + ? name(selectedModel) + : $i18n.t('All models')} + {#if comparing} + + {$i18n.t('Current')} + {$i18n.t('Previous')} + + {:else}{hovered + ? `${hovered.value.toLocaleString($i18n.language)} ${metricLabel.toLocaleLowerCase($i18n.language)}` + : metricLabel}{/if} +
+
+ {#if view === 'volume'} +
+ {#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as tick} +
+ {number(tick)} + +
+ {/each} +
+ {#if comparing} + + {/if} + {#each series as point, index (point.date)} + + {/each} +
+
+ {:else} +
{ + if (activityScrollTop !== activityViewport.scrollTop) { + activityScrollTop = activityViewport.scrollTop; + hideTooltip(); + } + }} + > +
+ {#each lanes as lane, offset (lane.id)} + {@const row = activityStart + offset} + {@const index = + Math.floor(keyboardIndex / series.length) === row ? keyboardIndex % series.length : 0} +
+ + +
+ {/each} +
+
+ {/if} + {#if hovered} + + {/if} +
+
+ {#each labels as index} + {dateLabel(series[index].date)} + {/each} +
+
+ + {#each legendIds as id (id)} + + {/each} +
+ {#if ids.length > legendLimit} + + {/if} +

+ {view === 'activity' + ? $i18n.t('Intensity relative to each model’s peak. Blank means no recorded usage.') + : $i18n.t('Select a model to isolate it.')} · {$i18n.t('Server time')} +

+
+ + diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index b63a655499..7a4a91ba1c 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -320,10 +320,18 @@ setSortKey('username')} + class="font-normal select-none w-3" + aria-sort={orderBy === 'username' + ? direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'} > -
+
+ setSortKey('model_id')} + class="font-normal select-none" + aria-sort={orderBy === 'model_id' + ? direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'} > -
+
+ setSortKey('rating')} + class="font-normal text-right select-none w-fit" + aria-sort={orderBy === 'rating' + ? direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'} > -
+
+ setSortKey('updated_at')} + class="font-normal text-right select-none w-0" + aria-sort={orderBy === 'updated_at' + ? direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'} > -
+
+ {#if feedback.data?.sibling_model_ids} -
openFeedbackModal(feedback)} > {feedback.data?.model_id} -
+
@@ -466,11 +500,13 @@ {:else} -
openFeedbackModal(feedback)} > {feedback.data?.model_id} -
+
{/if}
@@ -505,6 +541,7 @@ > diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 68246cc8dc..5427e546f5 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -177,13 +177,21 @@ {#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: $i18n.t('Model'), class: '' }, { key: 'rating', label: $i18n.t('Rating'), class: 'text-right w-fit' }, { key: 'won', label: $i18n.t('Won'), class: 'text-right w-5' }, { key: 'lost', label: $i18n.t('Lost'), class: 'text-right w-5' }] as col} toggleSort(col.key)} + class="font-normal select-none {col.class}" + aria-sort={orderBy === col.key + ? direction === 'asc' + ? 'ascending' + : 'descending' + : 'none'} > -
toggleSort(col.key)} > {$i18n.t(col.label)} {#if orderBy === col.key} @@ -193,7 +201,7 @@ {:else} {/if} -
+ {/each} @@ -221,8 +229,10 @@ }} /> - {model.name} openModal(model)}>{model.name} diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 075cd053e2..a8827cb414 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -25,6 +25,8 @@ } from '$lib/apis/functions'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; + import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte'; import { getModels } from '$lib/apis'; import FunctionMenu from './Functions/FunctionMenu.svelte'; @@ -408,6 +410,7 @@
- +
{/if} diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index 36a21b2477..195a85caae 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -639,6 +639,7 @@ class Pipe: value={selected.snapshot.content} lang="python" readOnly + ariaLabel={$i18n.t('Code Editor')} className="text-[0.6875rem]" /> {/if} @@ -648,6 +649,7 @@ class Pipe: value={content} readOnly={!!selected || selecting || promoting} lang="python" + ariaLabel={$i18n.t('Code Editor')} {boilerplate} className="text-[0.6875rem]" onChange={(e: string) => { diff --git a/src/lib/components/admin/Settings/AdminSettingRow.svelte b/src/lib/components/admin/Settings/AdminSettingRow.svelte index 530a92bcf9..785d98368b 100644 --- a/src/lib/components/admin/Settings/AdminSettingRow.svelte +++ b/src/lib/components/admin/Settings/AdminSettingRow.svelte @@ -9,10 +9,14 @@ const labelId = `admin-setting-${uuidv4()}`; -
-
+
+
+
diff --git a/src/lib/components/admin/Settings/Apps.svelte b/src/lib/components/admin/Settings/Apps.svelte index cf605103af..8e985e7a11 100644 --- a/src/lib/components/admin/Settings/Apps.svelte +++ b/src/lib/components/admin/Settings/Apps.svelte @@ -78,7 +78,9 @@
{:else}
{#each apps as app (app.id)} -
+
{:else}{/if} - {app.name} - {app.slug}{app.slug}
{#if app.is_active} @@ -122,6 +126,7 @@ : $i18n.t('Uninstalled')} > {#key `${app.is_active}-${show}-${busy}`} - +
{$i18n.t('{{COUNT}} selected', { @@ -467,6 +467,7 @@ })}
+ + + + @@ -183,8 +187,10 @@ 'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks\u2014proceed with extreme caution.' ) : $i18n.t('settings.admin.codeExecution.codeInterpreterEngine.description')} + let:labelId > diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index 207bf70dfe..bd8fbaeb9e 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -259,6 +259,7 @@
- diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 7f5c4dd93a..4087638711 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -438,8 +438,12 @@ - +
+
+ + + + +
+ - - - -
-
- -
-
-
- - - -
- +
+ {#if operation} + + {$i18n.t( + operation === 'download' + ? 'Preparing {{processed}} of {{total}}' + : 'Processed {{processed}} of {{total}}', + { processed: progress.done, total: progress.total } + )} + + {:else} + + + {#if query}{/if} + {#if selectedIds.size} + {$i18n.t('{{count}} selected', { count: selectedIds.size })} + + {/if} + {/if} +
- {#if query} -
- -
- {/if} -
+ {#if selectionStatus === 'loading'} +
+ {$i18n.t('Selecting all matching files...')}
+ {:else if selectionStatus === 'error'} + + {:else if allLoadedSelected && !allFilesLoaded && selectionStatus !== 'all' && !operation} + + {/if} - -
- {#if files !== null} -
- {#if files.length > 0} -
- - -
- {/if} - -
- {#if files.length === 0} -
- {$i18n.t('No files found')} -
- {/if} - - {#each files as file (file.id)} -
openFileViewer(file)} - > -
-
{file.filename}
-
- {formatFileSize(file.meta?.size ?? 0)} -
-
- -
- - {dayjs(file.created_at * 1000) - .locale($i18n.language) - .format('MMM D, YYYY')} - - -
- - - -
-
-
- {/each} - - {#if !allFilesLoaded} - { - if (!filesLoading) { - loadMoreFiles(); - } - }} - > -
- -
{$i18n.t('Loading...')}
-
-
- {/if} + {#if files !== null} + {#if files.length} +
+
+ 0 && !allLoadedSelected} + disabled={busy} + ariaLabel={$i18n.t('Select loaded files')} + on:change={() => + toggleSelection( + (files ?? []).map((file) => file.id), + !allLoadedSelected + )} + /> +
+ + {$i18n.t('Size')} + +
+ {/if} +
+ {#if !files.length && !loadError}
+ {$i18n.t('No files found')} +
{/if} + {#each sortedFiles as file (file.id)} +
+
+ toggleSelection([file.id], !selectedIds.has(file.id))} + />
+ + {formatFileSize(file.meta?.size ?? 0)} + + + +
+ + + + + +
+
- {:else} -
- + {/each} + {#if loadError} + + {:else if !allFilesLoaded && !busy} + loadFiles(true)} + >
{/if}
-
+ {:else} +
+ {/if}
diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index ffdf1c4be9..76cb6bac11 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -18,7 +18,8 @@ settings, folders, showEmbeds, - artifactContents + artifactContents, + visibleControls } from '$lib/stores'; import { getChatById } from '$lib/apis/chats'; @@ -389,7 +390,7 @@
{/if} - {#if ($artifactContents ?? []).length > 0} + {#if ($artifactContents ?? []).length > 0 && $visibleControls['chat.artifacts']}
+ + {/if} - - {#if !readOnly && !$temporaryChatEnabled && chat?.id}
@@ -517,18 +518,20 @@ {/if} - + {#if $visibleControls['chat.archive']} + + {/if} {#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index ceff835d44..2a5b530709 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -248,7 +248,7 @@ >{/if} +
@@ -1156,6 +1284,7 @@ class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region" href="/" draggable="false" + aria-label={$i18n.t('New Chat')} on:click={newChatHandler} > + + {/if} + - {/each} -
- -
-
- {/if} - -
-
- {#if $chats} - {#each $chats as chat, idx (`chat-${chat?.id ?? idx}`)} - {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)} -
- {$i18n.t(chat.time_range)} -
- {/if} - - { - selectedChatId = chat.id; - }} - on:unselect={() => { - selectedChatId = null; - }} - on:change={async () => { - initChatList(); - }} - onReadStateChange={applyChatReadState} - on:tag={(e) => { - const { type, name } = e.detail; - tagEventHandler(type, name, chat.id); - }} - /> - {/each} + {/each} +
{#if chatListReady && !allChatsLoaded} {#if type !== 'dm'} -
+
{#if type === ''} - + { + if (edit && channel?.id) { + const item = channel; + loading = true; + try { + await updateChannelAccessGrants(localStorage.token, item.id, grants); + item.access_grants = structuredClone(grants); + if (channel === item) accessGrants = item.access_grants; + toast.success($i18n.t('Saved')); + onUpdate(); + } catch (error) { + if (channel === item) + accessGrants = structuredClone(item.access_grants ?? []); + toast.error(`${error}`); + } finally { + loading = false; + } + } + }} + /> {:else if type === 'group'} {#if openPreview} @@ -20,15 +22,27 @@ {align} {sideOffset} > -
+
{title || $i18n.t('Chat')}
+ {#if turnCount !== null} + + {$i18n.t('{{count}} turns', { count: turnCount })} + + {/if}
diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 675082cf4c..1b5814c6bb 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -15,11 +15,16 @@ -{#each ownedList as folderId (folderId)} - { - dispatch('import', e.detail); - }} - on:update={(e) => { - dispatch('update', e.detail); - }} - on:change={(e) => { - dispatch('change', e.detail); - }} - /> -{/each} +{#if ownedList.length > 0} +
+ {#each ownedList as folderId (folderId)} + { + dispatch('import', e.detail); + }} + on:update={(e) => { + dispatch('update', e.detail); + }} + on:change={(e) => { + dispatch('change', e.detail); + }} + /> + {/each} +
+{/if} {#if sharedList.length > 0}
{$i18n.t('Shared')}
- {#each sharedList as folderId (folderId)} - { - dispatch('import', e.detail); - }} - on:update={(e) => { - dispatch('update', e.detail); - }} - on:change={(e) => { - dispatch('change', e.detail); - }} - /> - {/each} +
+ {#each sharedList as folderId (folderId)} + { + dispatch('import', e.detail); + }} + on:update={(e) => { + dispatch('update', e.detail); + }} + on:change={(e) => { + dispatch('change', e.detail); + }} + /> + {/each} +
{/if} diff --git a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte index 6762232773..ef86dd18a6 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte @@ -4,6 +4,8 @@ const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); + import { user } from '$lib/stores'; + import Dropdown from '$lib/components/common/Dropdown.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; import EditPencil from '../icons/EditPencil.svelte'; @@ -14,6 +16,7 @@ import CheckIcon from '$lib/components/icons/Check.svelte'; export let align: 'start' | 'end' = 'start'; + export let onNewChat = () => {}; export let onEdit = () => {}; export let onExport = () => {}; export let onDelete = () => {}; @@ -35,6 +38,7 @@ > + - + {#if $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true)} + + {/if}
diff --git a/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte b/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte index ddff2e2246..d5c905b391 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte @@ -33,6 +33,7 @@ background_image_url: null }; let data = { + description: '', model_ids: [] as string[], system_prompt: '', files: [] @@ -65,6 +66,7 @@ meta, // Sharing saves separately; do not overwrite its mode with the editor's snapshot. data: { + description: data.description.trim(), model_ids: data.model_ids, system_prompt: data.system_prompt, files: data.files @@ -92,6 +94,7 @@ system_prompt: '', files: [], ...folder.data, + description: folder.data?.description ?? '', model_ids: folder.data?.model_ids ?? [] }; } @@ -119,6 +122,7 @@ background_image_url: null }; data = { + description: '', model_ids: [], system_prompt: '', files: [] @@ -137,7 +141,7 @@ {/if}
- {#if edit && folder && ($user?.role === 'admin' || folder.user_id === $user?.id)} + {#if edit && folder && ($user?.role === 'admin' || (folder.user_id === $user?.id && $user?.permissions?.sharing?.folders))} (showAccessModal = true)} /> {/if}
+
+ +