Compare commits

...
308 Commits
Author SHA1 Message Date
Tim Jaeryang Baek 37d1c85c99 Merge pull request #17827 from open-webui/dev
0.6.32
2025-09-29 01:13:00 -05:00
Timothy Jaeryang Baek b6a485371d doc: changelog 2025-09-29 01:04:59 -05:00
Timothy Jaeryang Baek da236d5b38 chore: bump 2025-09-29 00:58:45 -05:00
Timothy Jaeryang Baek e7fa86aa26 chore: format 2025-09-29 00:58:21 -05:00
Timothy Jaeryang Baek 51a138aec3 refac: edit user modal 2025-09-29 00:51:06 -05:00
Timothy Jaeryang Baek aee7aaae68 refac 2025-09-29 00:35:21 -05:00
Tim Jaeryang Baek 2d94b8e905 Merge pull request #17837 from Classic298/milvus-multitenancy
feat: Impelement Milvus multitenancy // breaking: set milvus multitenancy as standard option (just like Qdrant already is)
2025-09-29 00:29:35 -05:00
Timothy Jaeryang Baek 8656bfc5f4 refac 2025-09-29 00:03:03 -05:00
Tim Jaeryang Baek d37f108ccd Merge pull request #17848 from Classic298/feat/group-permission-warning
Feat/chore: Add warning for conflicting group permissions / Refactored Permissions.svelte
2025-09-28 23:51:52 -05:00
Timothy Jaeryang Baek 88aca2e4d8 chore: format 2025-09-28 23:42:38 -05:00
Timothy Jaeryang Baek 234aa69753 refac 2025-09-28 22:16:11 -05:00
Timothy Jaeryang Baek f58fc753e3 feat/enh: embed citation 2025-09-28 22:15:47 -05:00
Timothy Jaeryang Baek 118549caf3 enh/fix: filter content metadata 2025-09-28 20:17:27 -05:00
Tim Jaeryang Baek 1c418a7f83 Merge pull request #17871 from silentoplayz/backend-json-model-import
feat: move JSON model import to backend for massive speedup
2025-09-28 19:25:40 -05:00
silentoplayz fe28097817 feat: refactor model import to a single backend endpoint
This refactors the model import functionality to improve performance and user experience by centralizing the logic on the backend.

Previously, the frontend would parse an imported JSON file and send an individual API request for each model, which was slow and inefficient.

This change introduces a new backend endpoint, `/api/v1/models/import`, that accepts a list of model objects. The frontend now reads the selected JSON file, parses it, and sends the entire payload to the backend in a single request. The backend then processes this list, creating or updating models as necessary.

This commit also includes the following fixes:
- Handles cases where the imported JSON contains models without `meta` or `params` fields by providing default empty values.
2025-09-28 18:49:42 -04:00
Timothy Jaeryang Baek 7bae9053ac refac: copy behaviour 2025-09-28 17:10:11 -05:00
silentoplayz 231d182c35 feat: move JSON model import to backend
This moves the JSON model import functionality to the backend. Instead of the frontend parsing the JSON file and sending multiple requests, it now uploads the file to a new endpoint (/api/v1/models/import), which processes the file and imports the models. This improves efficiency and provides better user feedback.
2025-09-28 18:09:58 -04:00
Timothy Jaeryang Baek fe54fb61aa fix: session middleware should be required by default 2025-09-28 16:35:13 -05:00
Timothy Jaeryang Baek 7e8ee46d2c refac 2025-09-28 15:18:12 -05:00
Timothy Jaeryang Baek da6748a68f refac 2025-09-28 14:59:30 -05:00
Timothy Jaeryang Baek 91b6483aa9 refac 2025-09-28 14:46:01 -05:00
Classic298 cf20f04bdc Update Permissions.svelte 2025-09-28 21:33:40 +02:00
Classic298 67b3929954 Delete src/lib/components/common/Warning.svelte 2025-09-28 21:27:31 +02:00
Classic298 ad9cae32dc Delete src/lib/components/icons/ExclamationTriangle.svelte 2025-09-28 21:27:20 +02:00
Classic298 a4e0c10f34 Delete src/lib/components/admin/Users/Groups/PermissionSwitch.svelte 2025-09-28 21:27:06 +02:00
Classic298 b1e63639cd ADD FAT WARNING - QDRANT 2025-09-28 21:17:07 +02:00
Classic298 0e99c43495 ADD FAT WARNING 2025-09-28 21:16:02 +02:00
Timothy Jaeryang Baek 4f06f29348 refac 2025-09-28 13:22:39 -05:00
Timothy Jaeryang Baek 4c2c58214c refac 2025-09-28 13:09:09 -05:00
Tim Jaeryang Baek 9a8c9ecb4d Merge pull request #17823 from ITOTI-Y/main
fix: correct messageId parameter in createMessagesList call
2025-09-28 12:57:56 -05:00
Timothy Jaeryang Baek 95462a394a refac 2025-09-28 12:54:31 -05:00
Tim Jaeryang Baek 11fd0735d1 Merge pull request #17851 from Classic298/feat-toggle-chat-title-in-browser-tab
feat: add toggle to show/hide chat title in browser tab
2025-09-28 12:53:19 -05:00
Tim Jaeryang Baek 2cb35e7142 Merge pull request #17843 from Classic298/feat/clone-default-group-permissions
feat/chore: Clone default group permissions for new groups and remove redundant modal
2025-09-28 12:50:59 -05:00
Tim Jaeryang Baek e71ed76165 Merge pull request #17857 from silentoplayz/feat-bulk-unarchive
feat: add backend handling for unarchiving all chats
2025-09-28 12:45:49 -05:00
Timothy Jaeryang Baek 742e2ff193 refac 2025-09-28 12:42:02 -05:00
Timothy Jaeryang Baek 3aad157006 refac 2025-09-28 12:26:13 -05:00
silentoplayz a572cf4842 feat: add backend handling for unarchiving all chats
The previous implementation for unarchiving all chats in `ArchivedChatsModal.svelte` was inefficient, as it sent a separate request for each chat, which could potentially overload the server.

This commit introduces a new backend endpoint, `/chats/unarchive/all`, to handle the bulk unarchiving of all chats for a user with a single API call.

The frontend has been updated to use this new endpoint, resolving the performance issue by minimizing the number of requests to the server.
2025-09-28 13:25:34 -04:00
Timothy Jaeryang Baek 97dae432ab refac 2025-09-28 12:23:20 -05:00
Timothy Jaeryang Baek db9d3d386d refac: mcp spec/response handling 2025-09-28 12:22:11 -05:00
Timothy Jaeryang Baek 4aa41aa139 refac 2025-09-28 12:00:19 -05:00
Tim Jaeryang Baek 5568a312cd Merge pull request #17835 from BakirBukvic/main
feat/ i18n: Add Bosnian (Latin) language
2025-09-28 11:48:35 -05:00
Tim Jaeryang Baek 2915bcf28c Merge pull request #17833 from ShirasawaSama/patch-20
i18n: improved Chinese (zh-CN & zh-TW) translation
2025-09-28 11:47:06 -05:00
Tim Jaeryang Baek 54ed62f7b0 Merge pull request #17832 from silentoplayz/pinned-chats-not-updating
fix: pinned chats not updating after archiving all chats
2025-09-28 11:46:53 -05:00
Tim Jaeryang Baek baf913c832 Merge pull request #17850 from silentoplayz/remove-unused-params
chore: Remove unused parameter definitions
2025-09-28 11:37:50 -05:00
google-labs-jules[bot] 57c02b2523 feat: add toggle to show/hide chat title in browser tab
This commit introduces a new setting in the Interface settings that allows users to control whether the chat title is used as the browser's tab title.

The following changes were made:
- Added `useChatTitleAsTabTitle` to the `Settings` type in `src/lib/stores/index.ts`.
- Added a toggle switch in `src/lib/components/chat/Settings/Interface.svelte` to manage this new setting.
- Updated `src/lib/components/chat/Chat.svelte` to conditionally set the document title based on the `useChatTitleAsTabTitle` setting.
2025-09-28 16:10:00 +00:00
silentoplayz 670dfca6a8 remove: used advanced params 2025-09-28 11:41:12 -04:00
Classic298 fb3eeaa126 Update Groups.svelte 2025-09-28 16:56:04 +02:00
google-labs-jules[bot] 30550d9190 Feat: Add warning for conflicting group permissions
This change introduces a visual warning in the group settings page. The warning appears when an admin attempts to disable a permission for a group that is already enabled in the default 'user' group. This is necessary because permissions are additive, and disabling a permission in a specific group will not revoke it if it's enabled in the default group.

To achieve this, the following changes were made:
- A new `PermissionSwitch.svelte` component was created to encapsulate the permission switch and its warning logic, avoiding redundant code.
- The `Groups.svelte` component was updated to correctly fetch the default user group's permissions.
- The `Permissions.svelte` component was refactored to use the new `PermissionSwitch.svelte` component, making the code cleaner and more maintainable.
2025-09-28 13:42:10 +00:00
google-labs-jules[bot] cafe748b1b feat(groups): Clone default group permissions for new groups
When creating a new user group, the permissions for the new group will now be pre-populated with the same permissions as the "default user group".

This is achieved by removing the separate `AddGroupModal` and instead using the `EditGroupModal` for both creating and editing groups. When creating a new group, the `EditGroupModal` is now pre-populated with the default permissions, saving administrators from having to manually configure them each time.

This change simplifies the codebase by removing a redundant component and directly addresses the user's request to streamline the group creation process.
2025-09-28 12:56:39 +00:00
Classic298 1773a4d4b8 typo 2025-09-28 11:14:27 +02:00
Classic298 27af4506df Update config.py 2025-09-28 11:10:37 +02:00
Classic298 01d4a8ab7a Update factory.py 2025-09-28 11:06:29 +02:00
Classic298 8dc43f9e3a Create milvus_multitenancy.py 2025-09-28 11:05:15 +02:00
Classic298 50a2204046 add env vars 2025-09-28 11:04:24 +02:00
bakir baed3035ac add bosnian to languages.json 2025-09-28 09:23:33 +02:00
bakir 88bfe777cd add Bosnian language pack 2025-09-28 09:19:51 +02:00
Shirasawa f912b06b42 i18n: improve Chinese translation 2025-09-28 03:39:00 +00:00
silentoplayz 2b5dca2929 fix: pinned chats not updating after archiving all chats
This commit fixes a UI bug where pinned chats would remain visible in the sidebar after all chats were archived from the Data Controls menu.

The `archiveAllChatsHandler` in `DataControls.svelte` has been updated to clear the `pinnedChats` store, ensuring the sidebar UI is correctly updated.
2025-09-27 22:44:04 -04:00
Tim Jaeryang Baek 2c80f60f3e Merge pull request #17812 from silentoplayz/long-text-truncation
fix: truncate long names in delete confirmation modals
2025-09-27 16:30:33 -05:00
Tim Jaeryang Baek 1283a8ce79 Merge pull request #17819 from silentoplayz/fix-layout-and-i18n
fix: i81n.t and correct button layout issue
2025-09-27 16:30:07 -05:00
ITOTI 68a4fdf0e9 fix: correct messageId parameter in createMessagesList call
- Fix FloatingButtons component to use messageId instead of id
2025-09-27 20:41:05 +00:00
silentoplayz 27cd87e9ad fix: i81n.t and correct button layout issue
1.  **i18n Regression:** A latent bug in `src/routes/(app)/workspace/models/create/+page.svelte` was causing an `i18n.t is not a function` error. This was due to an incorrect call to the `i18n` Svelte store. The fix corrects the call to use the proper auto-subscription syntax (`$i18n.t()`).

2.  **Vertical Button Text:** In `src/lib/components/playground/Chat.svelte`, the "Assistant"/"User" role button's text was displaying vertically. This was caused by a `flex-1` class on its container, which has been removed.
2025-09-27 15:45:25 -04:00
Timothy Jaeryang Baek 272c6f5ec5 refac 2025-09-27 04:38:54 -05:00
Timothy Jaeryang Baek 86ef57f6c3 refac 2025-09-27 04:33:00 -05:00
Timothy Jaeryang Baek b4eea78aff refac 2025-09-27 04:06:42 -05:00
Timothy Jaeryang Baek 1a18928c94 enh: reply to message 2025-09-27 04:05:12 -05:00
Timothy Jaeryang Baek d7c54d92b5 refac 2025-09-26 22:57:38 -05:00
silentoplayz e62f2b3c75 fix: truncate more long names in delete confirmation modals 2025-09-26 23:56:18 -04:00
Timothy Jaeryang Baek 45e1c72613 refac 2025-09-26 22:53:57 -05:00
Timothy Jaeryang Baek 23f62a7312 enh: tool server import/export 2025-09-26 22:53:02 -05:00
Timothy Jaeryang Baek d1a6c6b209 refac 2025-09-26 22:09:27 -05:00
Timothy Jaeryang Baek bad7d69a58 feat/enh: external tool server manual JSON spec 2025-09-26 22:02:48 -05:00
Timothy Jaeryang Baek a05dab6298 refac 2025-09-26 21:16:34 -05:00
Timothy Jaeryang Baek c80bb31968 refac/enh: folder optimization 2025-09-26 20:48:17 -05:00
Timothy Jaeryang Baek 54beeeaf72 refac: tools 2025-09-26 19:01:22 -05:00
Tim Jaeryang Baek 1de5827eb3 Merge pull request #17805 from silentoplayz/fix-username-truncation
fix: truncate long usernames in UI
2025-09-26 17:53:37 -05:00
Timothy Jaeryang Baek b77848244b refac: user valves 2025-09-26 17:49:42 -05:00
silentoplayz 16cf973ce5 fix: truncate long usernames in UI
Long usernames were causing layout issues in several parts of the application. This change truncates long usernames with an ellipsis to prevent them from overflowing.

The following areas have been fixed:
- Edit User modal
- User Chats modal
- Edit User Group modal
- Users table in the admin overview

fix: truncate long usernames in UI

Long usernames were causing layout issues in several parts of the application. This change truncates long usernames with an ellipsis to prevent them from overflowing.

The following areas have been fixed:
- Edit User modal
- User Chats modal
- Edit User Group modal
- Users table in the admin overview

Revert "fix: truncate long usernames in UI"

This reverts commit b623fdc95d0c494228b49f9369db3bbb3042cef0.
2025-09-26 18:30:48 -04:00
Tim Jaeryang Baek 807a8be299 Merge pull request #17767 from ShirasawaSama/patch-31
fix: fixed the hover effect for the MessageInput Integrations button
2025-09-26 16:58:15 -05:00
Tim Jaeryang Baek 5765a87720 Merge pull request #17779 from silentoplayz/fix-set-as-default
fix: 'Set as default' click position
2025-09-26 16:57:31 -05:00
Timothy Jaeryang Baek a1829f6a3e refac: styling 2025-09-26 16:55:38 -05:00
Timothy Jaeryang Baek b8c3e5ed3e refac 2025-09-26 16:43:12 -05:00
Tim Jaeryang Baek d10b518f42 Merge pull request #17801 from Classic298/feature/notes-public-sharing-permission
feat: add permission toggle for public sharing of notes
2025-09-26 16:11:17 -05:00
Timothy Jaeryang Baek 4997ef2662 refac 2025-09-26 15:57:03 -05:00
Timothy Jaeryang Baek cda4c95c86 fix: default tool calling 2025-09-26 15:50:16 -05:00
google-labs-jules[bot] 41e4e7395c feat: add permission toggle for public sharing of notes
This commit introduces a new permission toggle that allows administrators to control whether users can publicly share their notes.

- Adds a new environment variable `USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING` to control the default setting.
- Adds a `public_notes` permission to the `sharing` section of the user permissions.
- Adds a toggle switch to the admin panel for managing this permission.
- Implements backend logic to enforce the permission when a user attempts to share a note publicly.
2025-09-26 20:48:01 +00:00
Timothy Jaeryang Baek 680783266c refac/fix: tool response header type check 2025-09-26 15:42:17 -05:00
Timothy Jaeryang Baek 99d7773230 refac: styling 2025-09-26 15:00:06 -05:00
Timothy Jaeryang Baek ac08529380 refac 2025-09-26 14:40:30 -05:00
Tim Jaeryang Baek a80d5240f6 Merge pull request #17796 from silentoplayz/truncate-selector-tags
fix: truncate long filter tags in model selector and prevent wrapping
2025-09-26 14:35:58 -05:00
Tim Jaeryang Baek 9b86932714 Merge pull request #17797 from SZegotaM/dev
i18n(de-DE): add translations for newly added UI items + other translation fixes
2025-09-26 14:35:12 -05:00
Timothy Jaeryang Baek 0431ad9cc4 refac: get_discovery_urls 2025-09-26 14:34:26 -05:00
Tim Jaeryang Baek f8a3ed2d18 Merge pull request #17770 from Classic298/feat-milvus-diskann-support
feat: Add DISKANN index type support for Milvus
2025-09-26 14:23:53 -05:00
SZegotaM dcb0933149 Update German translations in translation.json
Added missing German translation
2025-09-26 21:16:35 +02:00
silentoplayz b516431569 fix: truncate long filter tags in model selector and prevent wrapping
This commit addresses an issue where long filter tags at the top of the model selector dropdown were not truncated correctly and would wrap to a new line, causing layout issues.

- A hard character limit of 16 characters is applied to the filter tags within the `Selector.svelte` component. Tags longer than 16 characters are truncated with an ellipsis (...) directly in the code. The full tag name remains available in the tooltip.
- The `whitespace-nowrap` class has been added to the tag container to ensure that the tags remain on a single, horizontally scrollable line.
2025-09-26 15:03:44 -04:00
Tim Jaeryang Baek af6a25eed7 Merge pull request #17769 from Classic298/fix-milvus-limit-error
Fix: milvus error because the limit set to None by default
2025-09-26 12:54:00 -05:00
Tim Jaeryang Baek acdafcd18d Merge pull request #17777 from ShirasawaSama/patch-33
fix: show error message when the uploading file is modified
2025-09-26 12:52:09 -05:00
Tim Jaeryang Baek a97cdc0da0 Merge pull request #17791 from silentoplayz/truncate-models-tag
Fix: truncate long model tags with a 32 characters limit
2025-09-26 12:46:45 -05:00
silentoplayz ac6292b812 Fix: truncate long model tags with a character limit
Long model tags on the Models page in the workspace section were not truncated consistently, which could cause layout issues.

This change implements a hard character limit of 32 characters on the model tags. Tags longer than 32 characters are truncated with an ellipsis (...) directly in the code. The full tag name remains available in the tooltip.
2025-09-26 13:28:35 -04:00
Tim Jaeryang Baek d126c23ef8 Merge pull request #17783 from joaoback/patch-10
Update translation.json (pt-BR)
2025-09-26 12:26:25 -05:00
joaoback 36da2b06a5 Update translation.json (pt-BR)
translation of the new items that were included in the latest version.
2025-09-26 09:18:27 -03:00
silentoplayz 90a5b3befb fix: 'Set as default' 2025-09-26 06:42:23 -04:00
Shirasawa 42faa63227 fix: show error message when the uploading file is modified 2025-09-26 17:31:16 +08:00
Classic298 9e3d5407ae Merge branch 'open-webui:main' into feat-milvus-diskann-support 2025-09-26 10:43:01 +02:00
Classic298 b550d78905 Merge branch 'open-webui:main' into fix-milvus-limit-error 2025-09-26 10:42:53 +02:00
google-labs-jules[bot] 123dbf152e feat: Add DISKANN index type support for Milvus
This commit introduces support for the DISKANN index type in the Milvus vector database integration.

Changes include:
- Added `MILVUS_DISKANN_MAX_DEGREE` and `MILVUS_DISKANN_SEARCH_LIST_SIZE` configuration variables.
- Updated the Milvus client to recognize and configure the DISKANN index type during collection creation.
2025-09-26 06:54:06 +00:00
google-labs-jules[bot] e7ccaf6e78 Fix: milvus error because the limit set to None by default
The pymilvus library expects -1 for unlimited queries, but the code was passing None, which caused a TypeError. This commit changes the default value of the limit parameter in the query method from None to -1. It also updates the call site in the get method to pass -1 instead of None and updates the type hint and a comment to reflect this change.
2025-09-26 06:39:54 +00:00
Shirasawa 15bd5040f1 fix: fixed the hover effect for the MessageInput Integrations button 2025-09-26 11:56:34 +08:00
Tim Jaeryang Baek 598282cf75 Merge pull request #17747 from open-webui/dev
0.6.31
2025-09-25 15:28:06 -05:00
Timothy Jaeryang Baek f7ea60b500 Update CHANGELOG.md 2025-09-25 15:26:26 -05:00
Timothy Jaeryang Baek 2fa222d00a refac 2025-09-25 15:20:13 -05:00
Timothy Jaeryang Baek 6920fed97e refac 2025-09-25 14:53:21 -05:00
Timothy Jaeryang Baek 528e58155b doc: changelog 2025-09-25 14:49:23 -05:00
Tim Jaeryang Baek 503fc354cc Merge pull request #17752 from Classic298/changelog-done
chore: Changelog 0.6.31
2025-09-25 14:45:34 -05:00
Classic298 4eec423950 Update CHANGELOG.md 2025-09-25 21:42:07 +02:00
Timothy Jaeryang Baek 52bc5306b0 refac 2025-09-25 14:37:30 -05:00
Timothy Jaeryang Baek 90e4b49b88 refac/fix: onedrive input menu styling issue 2025-09-25 14:36:25 -05:00
Timothy Jaeryang Baek 750a659a9f security: svg xss fix 2025-09-25 14:29:50 -05:00
Classic298 6fde0f2960 Update CHANGELOG.md 2025-09-25 21:20:39 +02:00
Timothy Jaeryang Baek a0e323b89b refac 2025-09-25 14:20:02 -05:00
Classic298 a68ac4efe6 Update CHANGELOG.md 2025-09-25 21:14:46 +02:00
Timothy Jaeryang Baek 393bdd7ead chore: bump 2025-09-25 14:13:12 -05:00
Timothy Jaeryang Baek 7f411dd5cc feat/enh: perplexity search support 2025-09-25 14:02:46 -05:00
Classic298 c790ec7625 Update CHANGELOG.md 2025-09-25 20:58:17 +02:00
Classic298 c46f3d05a8 Update CHANGELOG.md 2025-09-25 20:56:59 +02:00
Timothy Jaeryang Baek b38d59dee6 refac 2025-09-25 13:53:56 -05:00
Timothy Jaeryang Baek da661756fa refac/fix: proper notes db operations 2025-09-25 13:47:43 -05:00
Classic298 46967ffd04 Update CHANGELOG.md 2025-09-25 20:47:12 +02:00
Timothy Jaeryang Baek 5b1f9e3e21 refac: search modal input filters behaviour 2025-09-25 13:25:58 -05:00
Timothy Jaeryang Baek 776d4773a9 refac: note & chat sticky attachment 2025-09-25 13:21:47 -05:00
Timothy Jaeryang Baek eeed0df743 refac 2025-09-25 13:17:58 -05:00
Timothy Jaeryang Baek b1006a2b57 refac 2025-09-25 13:16:52 -05:00
Timothy Jaeryang BaekandJacob Leksan 53cd660de7 refac/fix: valves array type handling
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2025-09-25 13:13:03 -05:00
Tim Jaeryang Baek a7061383e8 Merge pull request #17753 from rgaricano/dev-es_ES
UPD: i18n _ es-ES Translation v0.6.31
2025-09-25 13:01:10 -05:00
Tim Jaeryang Baek 97529e8ae4 Merge pull request #17749 from ShirasawaSama/patch-2
i18n: improve zh-TW translation
2025-09-25 13:00:58 -05:00
Tim Jaeryang Baek c579e1fe44 Merge pull request #17748 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve zh-CN translation
2025-09-25 13:00:49 -05:00
Classic298 17080a1c00 REORDER BY PRIORITY 2025-09-25 19:41:54 +02:00
_00_ e6b3c91343 UPD: i18n - es-ES Translation v0.6.31
UPD: i18n - es-ES Translation v0.6.31

Update of new strings.
2025-09-25 19:39:12 +02:00
Classic298 deb64fa6a6 Update CHANGELOG.md 2025-09-25 19:37:54 +02:00
Classic298 9bc8784e3e Changelog dev (#20)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-09-25 19:37:18 +02:00
Shirasawa d6f93753c1 i18n: improve zh-TW translation 2025-09-26 01:23:33 +08:00
Shirasawa 0f12d512c4 i18n: improve zh-CN translation 2025-09-26 01:14:09 +08:00
Tim Jaeryang Baek b76d234f97 Merge pull request #17607 from sihyeonn/perf/sh-notes
perf: optimize notes query and separate access control logic
2025-09-25 12:13:42 -05:00
Tim Jaeryang Baek 4aa5574ba7 Merge pull request #17746 from andrewbbaek/dev
chore: add IDs to elements for better maintainability
2025-09-25 12:07:35 -05:00
Andrew Baek ead1c1881c Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-09-26 01:57:38 +09:00
Andrew Baek 3df5826d93 moved sidebar-folder-button from class to id 2025-09-26 01:56:45 +09:00
Tim Jaeryang Baek cd417ca0ba Merge pull request #17744 from Classic298/fix-rag-full-context
Fix: Prevent RAG queries when all files are in full context
2025-09-25 11:55:41 -05:00
Timothy Jaeryang Baek 4f8f8e0e0b chore: dep bump 2025-09-25 11:51:04 -05:00
Timothy Jaeryang Baek 05d7d1e562 chore: format 2025-09-25 11:43:10 -05:00
Andrew Baek 08f4a2dff8 Merge branch 'open-webui:dev' into dev 2025-09-26 01:31:20 +09:00
Timothy Jaeryang Baek ff44fa242d refac 2025-09-25 11:28:33 -05:00
Andrew Baek e4f27ab75f Merge branch 'open-webui:dev' into dev 2025-09-26 01:25:50 +09:00
Andrew Baek 86f024b9d7 added id and class 2025-09-26 01:23:58 +09:00
Timothy Jaeryang Baek 3c7d01163d refac 2025-09-25 11:02:49 -05:00
google-labs-jules[bot] 4a7e1b93e5 Fix: Prevent RAG queries when all files are in full context
This commit fixes an issue where Retrieval-Augmented Generation (RAG)
queries were still being generated even when all attached files were set
to 'full context' mode. This was inefficient as the full content of the
files was already available to the model.

The `chat_completion_files_handler` in `backend/open_webui/utils/middleware.py`
has been updated to:
- Check if all attached files have the `context: 'full'` property.
- Skip the `generate_queries` step if all files are in full context mode.
- Pass a `full_context=True` flag to the `get_sources_from_items`
  function to ensure it fetches the entire document content instead of
  performing a vector search.

This change ensures that RAG queries are only generated when necessary,
improving the efficiency of the system.
2025-09-25 15:54:58 +00:00
Timothy Jaeryang Baek 98883a68f2 refac 2025-09-25 02:18:10 -05:00
Tim Jaeryang Baek b8a51de977 Merge pull request #17721 from Classic298/fix-image-download-filename
fix: Use generic filename for downloaded images
2025-09-25 02:02:19 -05:00
Timothy Jaeryang Baek cd7bd0aa20 refac 2025-09-25 02:00:02 -05:00
Timothy Jaeryang Baek 4d16cf6bf3 refac 2025-09-25 01:59:18 -05:00
Timothy Jaeryang Baek 422d38fd11 refac 2025-09-25 01:56:09 -05:00
Timothy Jaeryang Baek 879abd7fee refac 2025-09-25 01:53:10 -05:00
Timothy Jaeryang Baek 77e971dd9f feat: oauth2.1 mcp integration 2025-09-25 01:49:16 -05:00
Classic298 4f22a1c5e4 Update translation.json 2025-09-25 07:55:17 +02:00
Classic298 e201dc995c Update ImagePreview.svelte 2025-09-25 07:54:58 +02:00
Timothy Jaeryang Baek 972be4eda5 enh: oauth2.1 dynamic client registration 2025-09-25 00:28:13 -05:00
Timothy Jaeryang Baek 34d16791a8 refac 2025-09-24 23:27:45 -05:00
Tim Jaeryang Baek d3b09c6a02 Merge pull request #17223 from itk-dev/feature/session-in-redis
feat: Added support for redis as session storage
2025-09-24 23:23:56 -05:00
Timothy Jaeryang Baek 27d61307cd refac: tools valves 2025-09-24 21:12:25 -05:00
Timothy Jaeryang Baek 685cca5bd5 chore: dep bump 2025-09-24 18:58:17 -05:00
Timothy Jaeryang Baek bd02a456ab chore: langchain dep bump 2025-09-24 18:55:33 -05:00
Timothy Jaeryang Baek e5daec32ba refac 2025-09-24 17:16:07 -05:00
Timothy Jaeryang Baek b03529b006 refac 2025-09-24 17:05:37 -05:00
Timothy Jaeryang Baek 612a52d7bb refac 2025-09-24 16:34:41 -05:00
Timothy Jaeryang Baek aa6f63a335 enh: search modal actions 2025-09-24 16:29:02 -05:00
Timothy Jaeryang Baek 9f0010e234 enh: channel model @ image support 2025-09-24 16:08:36 -05:00
Timothy Jaeryang Baek e7061b70e9 refac 2025-09-24 16:01:31 -05:00
Timothy Jaeryang Baek fe65fe0b97 refac: ollama cloud web search count support 2025-09-24 15:58:56 -05:00
Tim Jaeryang Baek 3d2f7e3143 Merge pull request #17715 from ShirasawaSama/revert-17498-patch-19
Revert "feat: Dynamically load CodeEditor.svelte to improve first-screen loading speed (-1MB)"
2025-09-24 15:40:08 -05:00
Timothy Jaeryang Baek 1df5f1f9f6 revert: code editor dynamic import 2025-09-24 15:39:55 -05:00
Timothy Jaeryang Baek 05c46008da refac 2025-09-24 15:38:09 -05:00
Tim Jaeryang Baek 1493a5fd32 Merge pull request #17722 from Classic298/bugfix/hidden-models-in-notes
Fix: Hide hidden models in notes section
2025-09-24 15:37:52 -05:00
Timothy Jaeryang Baek 4b6d34438b refac 2025-09-24 15:20:31 -05:00
Timothy Jaeryang Baek e06489d92b enh: search_ollama_cloud 2025-09-24 15:19:05 -05:00
google-labs-jules[bot] c32b407f8a Fix(notes): Hide hidden models in notes section
This commit fixes a bug where hidden models were still visible and selectable in the notes section. The following changes were made:

- The model dropdown in the chat section of the note editor is now filtered to exclude hidden models.
- The default model selection logic in the note editor is now filtered to exclude hidden models.
2025-09-24 19:43:07 +00:00
google-labs-jules[bot] ab30b105e2 fix(image): Use generic filename for downloaded images
Previously, when downloading a generated image, the filename was set to the AI's response text. This was not ideal as the response text could be long and contain characters that are not suitable for filenames.

This commit changes the behavior to use a generic, translatable filename for downloaded images. The new filename is 'ai_generated_image' (or its translation), which is more user-friendly and consistent.
2025-09-24 19:39:08 +00:00
Timothy Jaeryang Baek f27ab14bd8 refac 2025-09-24 12:37:19 -05:00
Timothy Jaeryang Baek 23a51f2d01 refac: robust file upload failed handling 2025-09-24 12:17:01 -05:00
Timothy Jaeryang Baek 05732de898 refac 2025-09-24 11:36:17 -05:00
Tim Jaeryang Baek 316f1d6ff0 Merge pull request #17717 from Classic298/patch-1
add youtube
2025-09-24 12:34:37 -04:00
Classic298 b4d8287946 add youtube 2025-09-24 18:31:48 +02:00
Timothy Jaeryang Baek 0dee15ba97 refac/enh: include foldered chats in ref chat input menu 2025-09-24 11:27:19 -05:00
Timothy Jaeryang Baek 0e3b6b3b8f refac 2025-09-24 11:22:48 -05:00
Timothy Jaeryang Baek 32086b6ecd refac 2025-09-24 11:20:39 -05:00
Timothy Jaeryang Baek 2a95cbcef7 enh: attach webpage input menu 2025-09-24 11:11:26 -05:00
Shirasawa 6614c73b6c Revert "feat: Dynamically load CodeEditor.svelte to improve first-screen loading speed (-1MB)" 2025-09-25 00:10:01 +08:00
Timothy Jaeryang Baek 2904a78222 refac 2025-09-24 10:14:38 -05:00
Timothy Jaeryang Baek ac879513e5 enh: channel read/write perm 2025-09-24 10:09:59 -05:00
Timothy Jaeryang Baek 6d69ea3ac7 refac 2025-09-24 09:46:19 -05:00
Timothy Jaeryang Baek f096e99059 refac: rag context handling 2025-09-24 09:38:14 -05:00
Timothy Jaeryang Baek 72d19e44dc refac 2025-09-24 09:37:37 -05:00
Timothy Jaeryang Baek b25c7da154 refac: valves 2025-09-24 09:21:53 -05:00
Timothy Jaeryang Baek b8086c5edf refac: folder delete logic 2025-09-24 09:04:54 -05:00
Timothy Jaeryang Baek 91a9f32904 fix: folder expanded update 2025-09-24 08:00:58 -05:00
Timothy Jaeryang Baek 199f371f58 refac 2025-09-24 07:17:41 -05:00
Tim Jaeryang Baek 6cdadc73f2 Merge pull request #17705 from aleixdorca/dev
i18n: Update the Catalan translation file
2025-09-24 07:57:16 -04:00
Timothy Jaeryang Baek c5a967e05f refac 2025-09-24 06:56:50 -05:00
Timothy Jaeryang Baek 651f385ba5 fix: oauth refresh server metadata 2025-09-24 06:56:24 -05:00
Timothy Jaeryang Baek f25a144e09 refac 2025-09-24 06:52:44 -05:00
Timothy Jaeryang Baek 5eaee44daa refac 2025-09-24 06:49:39 -05:00
Aleix Dorca 9c76fb267a Update catalan translation.json 2025-09-24 11:21:27 +02:00
Aleix Dorca cf49b823b0 Merge branch 'open-webui:dev' into dev 2025-09-24 10:59:32 +02:00
Andrew Baek 5be58f2601 Merge branch 'open-webui:dev' into dev 2025-09-24 17:10:48 +09:00
Timothy Jaeryang Baek 1c64269387 refac 2025-09-23 16:51:31 -05:00
Timothy Jaeryang Baek f33d6eabdf chore: bump azure identity 2025-09-23 16:48:31 -05:00
Tim Jaeryang Baek 7ae42f5ae3 Merge pull request #17660 from HikkaTown/i18n/russian-translation-updated
i18n: Complete Russian translation for Open WebUI
2025-09-23 17:30:08 -04:00
Tim Jaeryang Baek 9710060c27 Merge pull request #17645 from ShirasawaSama/patch-28
feat: only retain one language supports for highlight.js to reduce bundle size (-1.58MB)
2025-09-23 16:28:05 -04:00
Timothy Jaeryang Baek 06e5c00f24 refac 2025-09-23 03:52:43 -04:00
Timothy Jaeryang Baek f7b0fdde2a refac 2025-09-23 03:42:25 -04:00
Timothy Jaeryang Baek 7aa3903b6b refac 2025-09-23 03:36:11 -04:00
Timothy Jaeryang Baek 6bf4ba523d refac 2025-09-23 03:35:00 -04:00
Timothy Jaeryang Baek c42c0436f2 refac 2025-09-23 03:33:24 -04:00
Timothy Jaeryang Baek 61f20acf61 refac 2025-09-23 03:32:25 -04:00
Timothy Jaeryang Baek c55afc4255 refac 2025-09-23 03:19:36 -04:00
Timothy Jaeryang Baek f1bbf3a91e refac 2025-09-23 03:05:38 -04:00
Timothy Jaeryang Baek de7f7b3d85 refac 2025-09-23 02:40:59 -04:00
Timothy Jaeryang Baek 777e81f7a8 feat: experimental mcp support 2025-09-23 02:03:26 -04:00
Timothy Jaeryang Baek aeb5288a3c refac 2025-09-22 23:28:19 -04:00
Timothy Jaeryang Baek 2771c26729 refac 2025-09-22 20:03:44 -04:00
Timothy Jaeryang Baek e4e97e727e enh: note drag handle 2025-09-22 20:02:37 -04:00
Timothy Jaeryang Baek 1afa366dcb refac 2025-09-22 14:46:47 -04:00
Zaytsev Gennadiy 7c1b6f9157 i18n: Complete Russian translation for Open WebUI 2025-09-22 21:35:57 +03:00
Tim Jaeryang Baek 651dea3864 Merge pull request #17640 from ShirasawaSama/patch-27
feat: do not initiate requests when the SearchModal is not open to speed up page loading
2025-09-22 13:27:56 -04:00
Tim Jaeryang Baek 96e71cf181 Merge pull request #17647 from BoFFire/patch-2
i18n: updating kabyle translation
2025-09-22 13:22:20 -04:00
Tim Jaeryang Baek ac5cc4a050 Merge pull request #17657 from helax28/dev
Updated the french translations
2025-09-22 13:22:00 -04:00
ButterflyOfFire 48dc333a3d Update translation.json 2025-09-22 10:28:27 +01:00
ButterflyOfFire 628b753a35 Update translation.json 2025-09-22 10:07:54 +01:00
ButterflyOfFire c28fbcb232 i18n: updating kabyle translation
Translation progress : 90%
2025-09-22 09:51:55 +01:00
Shirasawa 2edfde1990 feat: only retain one language supports for highlight.js to reduce bundle size 2025-09-22 16:00:46 +08:00
Shirasawa 01fcd057b7 feat: do not initiate requests when the SearchModal is not open to speed up page loading 2025-09-22 12:01:29 +08:00
Tim Jaeryang Baek fb3b736a35 Merge pull request #17635 from open-webui/main
dev
2025-09-21 19:15:06 -04:00
athiththan 064d9d6141 Updated the french translations 2025-09-21 21:45:31 +02:00
Timothy Jaeryang Baek fd7385c392 refac 2025-09-21 03:12:24 -04:00
Timothy Jaeryang Baek 834824ce7b refac 2025-09-21 03:06:54 -04:00
Timothy Jaeryang Baek 466d5bb696 refac: add separate Client IDs for OneDrive 2025-09-21 01:40:14 -04:00
Timothy Jaeryang Baek 6e4a2f18e1 refac 2025-09-21 00:14:43 -04:00
Tim Jaeryang Baek 5e6e25f8c8 Merge pull request #17615 from Cyp9715/dev
i18n: Updated korean translation.
2025-09-20 23:07:01 -05:00
Tim Jaeryang Baek 428f9a22c4 Merge pull request #17616 from Kylapaallikko/dev
i18n: Update fi-FI translation.json
2025-09-20 16:10:06 -05:00
Tim Jaeryang Baek 92d79150bd Merge pull request #17618 from rgaricano/dev-es_ES
UPD: i18n es-ES Translation v.0.6.30
2025-09-20 16:04:13 -05:00
Timothy Jaeryang Baek 6bc5d331a2 doc: readme 2025-09-20 12:45:10 -05:00
Andrew Baek a7ae280a4f Update MessageInput.svelte 2025-09-21 01:01:29 +09:00
Andrew Baek 2a1c1e34dc Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-09-21 00:52:28 +09:00
_00_ 4b4fda46cf Update translation.json 2025-09-20 14:46:18 +02:00
_00_ 4b029048db UPD: i18n es-ES Translation v.0.6.30
### Update i18n es-ES Translation v.0.6.30

Added new strings
2025-09-20 14:39:23 +02:00
Kylapaallikko 34feaeeb96 Update fi-FI translation.json
Added missing translations and fixed typos
2025-09-20 14:53:04 +03:00
ByoungGyu Lee a6c53303dd i18n(ko-KR): Updated korean translation. 2025-09-20 20:44:51 +09:00
ByoungGyu Lee 204484d8e7 i18n(ko-KR): Updated korean translations. 2025-09-20 20:40:50 +09:00
ByoungGyu Lee 41e4f33c1d Merge remote-tracking branch 'remote/dev' into dev 2025-09-20 19:43:01 +09:00
Timothy Jaeryang Baek 94770f6059 refac 2025-09-20 01:33:36 -05:00
Timothy Jaeryang Baek c4fed37af9 refac 2025-09-20 01:25:44 -05:00
Timothy Jaeryang Baek b0279c5090 refac 2025-09-20 01:25:00 -05:00
Timothy Jaeryang Baek 73f8237d43 refac 2025-09-20 01:19:23 -05:00
Timothy Jaeryang Baek 4ad7430986 refac 2025-09-20 01:15:27 -05:00
Timothy Jaeryang Baek 27a91cc80a refac: styling 2025-09-20 00:56:25 -05:00
Tim Jaeryang Baek 820321ad55 Merge pull request #17608 from sihyeonn/fix/sh-prevent-memory-leaks-on-ollama
fix: prevent memory leaks in file handling and HTTP connections
2025-09-19 22:22:08 -05:00
Sihyeon Jang 7042318c34 fix: prevent memory leaks in file handling and HTTP connections
- Fix file handle memory leak in download_file_stream by properly closing and reopening files
- Add requests.Session context manager for proper HTTP connection cleanup
- Remove unnecessary file.seek(0) after file reopening
- Add timeout to prevent hanging connections

This prevents memory accumulation during large file downloads and ensures
proper resource cleanup in all scenarios.

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-20 06:37:10 +09:00
Sihyeon Jang 6ae6cc9741 perf: optimize get_notes_by_user_id to reduce database queries
- Replace inefficient memory-based filtering with database-level filtering
- Add proper access control conditions to SQL query
- Reduce memory usage by filtering at database level instead of loading all notes
- Maintain access control validation with post-filtering for complex cases

This change significantly improves performance for users with many notes
by reducing the number of database queries and memory usage.

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-20 06:33:24 +09:00
Tim Jaeryang Baek dbd7b90123 Merge pull request #17593 from ShirasawaSama/patch-25
refactor: refactor editor's collaboration to reduce package size (-390KB) and minimize compile errors
2025-09-19 13:16:28 -05:00
Tim Jaeryang Baek afad34fd09 Merge pull request #17600 from aindriu80/update-ga-strings-19-sept
i18n: Updated Irish (ga) translation
2025-09-19 13:13:28 -05:00
Aindriú Mac Giolla Eoin 167f4ba8ad i18n(ga): Updated Irish translations 2025-09-19 16:02:56 +01:00
Shirasawa 7f6b260c35 feat: refactor editor's collaboration to reduce package size and minimize errors 2025-09-19 17:28:16 +08:00
Timothy Jaeryang Baek 62517f01e5 refac: built-in chart js 2025-09-19 02:58:29 -05:00
Timothy Jaeryang Baek 293531549c refac: alpine injection 2025-09-19 02:19:48 -05:00
Timothy Jaeryang Baek 60db9ec8ef refac: built-in tools ui component support 2025-09-19 01:38:44 -05:00
Timothy Jaeryang Baek 7528f24b61 refac: styling 2025-09-19 00:15:03 -05:00
Timothy Jaeryang Baek e4c4ba0979 fix: oauth token 2025-09-19 00:10:48 -05:00
Timothy Jaeryang Baek a89ffccd7e refac 2025-09-19 00:10:39 -05:00
Timothy Jaeryang Baek 7be5b7f50f refac 2025-09-18 22:03:14 -05:00
Timothy Jaeryang Baek a5d8882bba refac 2025-09-18 21:25:26 -05:00
Timothy Jaeryang Baek 07c5b25bc8 feat: tool ui element support 2025-09-18 20:55:23 -05:00
Cyp 10c9a7d75e Merge branch 'dev' of https://github.com/open-webui/open-webui into dev 2025-09-19 08:59:21 +09:00
Cyp 78e7f14360 Merge branch 'main' of https://github.com/open-webui/open-webui into dev 2025-09-19 08:59:11 +09:00
Timothy Jaeryang Baek 700894a13d refac: channel modal 2025-09-18 18:02:35 -05:00
Timothy Jaeryang Baek 1c78594379 refac 2025-09-18 17:53:11 -05:00
Timothy Jaeryang Baek 48cd72fab0 refac 2025-09-18 17:51:00 -05:00
Timothy Jaeryang Baek 04251b7bff refac 2025-09-18 17:50:39 -05:00
Tim Jaeryang Baek e150d8763b Merge pull request #17551 from ShirasawaSama/patch-20
i18n: improve Chinese translation
2025-09-18 17:49:09 -05:00
Tim Jaeryang Baek 0159a33306 Merge pull request #17542 from ShirasawaSama/patch-23
feat: do not initiate requests when the ChangelogModal is not open to speed up page loading
2025-09-18 17:48:51 -05:00
Timothy Jaeryang Baek c07086401e refac 2025-09-18 17:47:32 -05:00
Tim Jaeryang Baek 2de11f8daf Merge pull request #17577 from joaoback/patch-9
Update translation.json (pt-BR)
2025-09-18 17:09:03 -05:00
Tim Jaeryang Baek 0e75e8529e Merge pull request #17562 from rgaricano/dev-HybridSearch-bm25_slider
FIX: Inconsistency in the description of BM25 - Update Documents.svelte
2025-09-18 17:07:43 -05:00
Tim Jaeryang Baek 40da51d37f Merge pull request #17546 from Cyp9715/dev
i18n: improve Korean translation
2025-09-18 17:06:15 -05:00
joaoback bfc8f74477 Update translation.json (pt-BR)
translation of newly added items.
2025-09-18 13:53:41 -03:00
Timothy Jaeryang Baek d4f21c7e84 refac 2025-09-18 10:43:59 -05:00
Timothy Jaeryang Baek b9e200f104 fix: isdangerous dep 2025-09-18 09:39:40 -05:00
_00_ da7610f6cb FIX: Inconsistency in the description of BM25 - Update Documents.svelte
FIX: Inconsistency in the description of BM25

Fix issue: https://github.com/open-webui/open-webui/discussions/17553
2025-09-18 13:18:07 +02:00
Shirasawa f188d570f7 feat: improve Chinese translation 2025-09-18 07:48:19 +00:00
bglee 52e8c6c998 Update on the Korean translation 2025-09-18 15:09:43 +09:00
Shirasawa 7735a04783 feat: do not initiate requests when the ChangelogModal is not open 2025-09-18 11:50:00 +08:00
Andrew Baek b0720ec2aa Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-09-16 01:07:15 +09:00
Andrew Baek f704d5b0b3 Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-09-12 19:23:46 +09:00
Jesper Kristensen 4ca43004ed feat: Added support for redis as session storage 2025-09-10 14:10:40 +02:00
Aleix Dorca 7c0bf59c7c Update catalan translation.json 2025-09-05 17:17:20 +02:00
Andrew Baek b50ae5bf69 Update MessageInput.svelte 2025-08-31 14:48:37 +09:00
Andrew Baek 05223f720d Merge branch 'open-webui:dev' into dev 2025-08-29 22:12:04 +09:00
Andrew Baek ceaafbbfd2 Update groups.py 2025-08-27 03:22:34 +09:00
Andrew Baek 7b0ce47eb8 Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-08-27 03:08:21 +09:00
Andrew Baek a64586f9c2 Merge branch 'dev' of https://github.com/andrewbbaek/open-webui into dev 2025-08-27 02:57:43 +09:00
Andrew Baek e214d59d10 Update groups.py fix issue #16870 2025-08-27 01:04:27 +09:00
230 changed files with 13201 additions and 4069 deletions
+74
View File
@@ -5,6 +5,80 @@ 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.6.32] - 2025-09-29
### Added
- 🗝️ Permission toggle for public sharing of notes was added, allowing note owners to quickly enable or disable public access from the note settings interface.
- ⚠️ A warning is now displayed in the user edit modal if conflicting group permissions are detected, helping administrators resolve access control ambiguities before saving changes.
### Fixed
- 🧰 Fixed regression where External Tool servers (OpenAPI) were nonfunctional after the 0.6.31 update; external tools integration is now restored and reliable.
- 🚑 Resolved a critical bug causing Streamable HTTP OAuth 2.1 (MCP server) integrations to throw a 500 error on first invocation due to missing 'SessionMiddleware'. OAuth 2.1 registration now succeeds and works on subsequent requests as expected.
- 🐛 The "Set as default" option is now reliably clickable in model and filter selection menus, fixing cases where the interface appeared unresponsive.
- 🛠️ Embed UI now works seamlessly with both default and native function calling flows, ensuring the tool embedding experience is consistent regardless of invocation method.
- 🧹 Addressed various minor UI bugs and inconsistencies for a cleaner user experience.
### Changed
- 🧬 MCP tool result handling code was refactored for improved parsing and robustness of tool outputs.
- 🧩 The user edit modal was overhauled for clarity and usability, improving the organization of group, permission, and public sharing controls.
## [0.6.31] - 2025-09-25
### Added
- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
### Fixed
- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
### Changed
- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
## [0.6.30] - 2025-09-17
### Added
+11
View File
@@ -0,0 +1,11 @@
# Open WebUI Multi-License Notice
This repository contains code governed by multiple licenses based on the date and origin of contribution:
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
For details on which commits are covered by which license, refer to LICENSE_HISTORY.
+1 -1
View File
@@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
## Support 💬
+34 -9
View File
@@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str
_state: dict[str, PersistentConfig]
def __init__(
self,
redis_url: Optional[str] = None,
@@ -233,9 +234,8 @@ class AppConfig:
redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui",
):
super().__setattr__("_state", {})
super().__setattr__("_redis_key_prefix", redis_key_prefix)
if redis_url:
super().__setattr__("_redis_key_prefix", redis_key_prefix)
super().__setattr__(
"_redis",
get_redis_connection(
@@ -246,6 +246,8 @@ class AppConfig:
),
)
super().__setattr__("_state", {})
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
self._state[key] = value
@@ -1215,6 +1217,11 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
@@ -1352,6 +1359,7 @@ DEFAULT_USER_PERMISSIONS = {
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
@@ -1997,16 +2005,23 @@ if VECTOR_DB == "chroma":
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
)
ENABLE_MILVUS_MULTITENANCY_MODE = (
os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "true").lower() == "true"
)
# Hyphens not allowed, need to use underscores in collection names
MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)
@@ -2168,6 +2183,8 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
"onedrive.enable",
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
ENABLE_ONEDRIVE_PERSONAL = (
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
)
@@ -2175,10 +2192,12 @@ ENABLE_ONEDRIVE_BUSINESS = (
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
)
ONEDRIVE_CLIENT_ID = PersistentConfig(
"ONEDRIVE_CLIENT_ID",
"onedrive.client_id",
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
"ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
@@ -2761,6 +2780,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
)
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
"rag.web.search.ollama_cloud_api_key",
os.getenv("OLLAMA_CLOUD_API_KEY", ""),
)
SEARXNG_QUERY_URL = PersistentConfig(
"SEARXNG_QUERY_URL",
"rag.web.search.searxng_query_url",
+7 -3
View File
@@ -474,6 +474,10 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
)
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
@@ -547,16 +551,16 @@ else:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
)
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
else:
try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
####################################
+7 -1
View File
@@ -86,6 +86,10 @@ async def get_function_models(request):
try:
function_module = get_function_module_by_id(request, pipe.id)
has_user_valves = False
if hasattr(function_module, "UserValves"):
has_user_valves = True
# Check if function is a manifold
if hasattr(function_module, "pipes"):
sub_pipes = []
@@ -124,6 +128,7 @@ async def get_function_models(request):
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
}
)
else:
@@ -141,6 +146,7 @@ async def get_function_models(request):
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
}
)
except Exception as e:
@@ -239,7 +245,7 @@ async def generate_function_chat_completion(
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token(
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
+96 -8
View File
@@ -50,6 +50,11 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers
from starsessions import (
SessionMiddleware as StarSessionsMiddleware,
SessionAutoloadMiddleware,
)
from starsessions.stores.redis import RedisStore
from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
@@ -269,6 +274,7 @@ from open_webui.config import (
WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV,
WEB_SEARCH_DOMAIN_FILTER_LIST,
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
@@ -301,7 +307,8 @@ from open_webui.config import (
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
ENABLE_ONEDRIVE_INTEGRATION,
ONEDRIVE_CLIENT_ID,
ONEDRIVE_CLIENT_ID_PERSONAL,
ONEDRIVE_CLIENT_ID_BUSINESS,
ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID,
ENABLE_ONEDRIVE_PERSONAL,
@@ -466,7 +473,12 @@ from open_webui.utils.auth import (
get_verified_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import OAuthManager
from open_webui.utils.oauth import (
OAuthManager,
OAuthClientManager,
decrypt_data,
OAuthClientInformationFull,
)
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.redis import get_redis_connection
@@ -596,9 +608,14 @@ app = FastAPI(
lifespan=lifespan,
)
# For Open WebUI OIDC/OAuth2
oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager
# For Integrations
oauth_client_manager = OAuthClientManager(app)
app.state.oauth_client_manager = oauth_client_manager
app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
@@ -882,6 +899,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME
@@ -1530,6 +1549,14 @@ async def chat_completion(
except:
pass
finally:
try:
if mcp_clients := metadata.get("mcp_clients"):
for client in mcp_clients.values():
await client.disconnect()
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
if (
metadata.get("session_id")
@@ -1743,7 +1770,8 @@ async def get_app_config(request: Request):
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {
"client_id": ONEDRIVE_CLIENT_ID.value,
"client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
},
@@ -1863,17 +1891,76 @@ async def get_current_usage(user=Depends(get_verified_user)):
# OAuth Login & Callback
############################
# SessionMiddleware is used by authlib for oauth
if len(OAUTH_PROVIDERS) > 0:
# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
if tool_server_connection.get("type", "openapi") == "mcp":
server_id = tool_server_connection.get("info", {}).get("id")
auth_type = tool_server_connection.get("auth_type", "none")
if server_id and auth_type == "oauth_2.1":
oauth_client_info = tool_server_connection.get("info", {}).get(
"oauth_client_info", ""
)
oauth_client_info = decrypt_data(oauth_client_info)
app.state.oauth_client_manager.add_client(
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
)
try:
if REDIS_URL:
redis_session_store = RedisStore(
url=REDIS_URL,
prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
)
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(
StarSessionsMiddleware,
store=redis_session_store,
cookie_name="owui-session",
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
)
log.info("Using Redis for session")
else:
raise ValueError("No Redis URL provided")
except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="oui-session",
session_cookie="owui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)
@app.get("/oauth/clients/{client_id}/authorize")
async def oauth_client_authorize(
client_id: str,
request: Request,
response: Response,
user=Depends(get_verified_user),
):
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
@app.get("/oauth/clients/{client_id}/callback")
async def oauth_client_callback(
client_id: str,
request: Request,
response: Response,
user=Depends(get_verified_user),
):
return await oauth_client_manager.handle_callback(
request,
client_id=client_id,
user_id=user.id if user else None,
response=response,
)
@app.get("/oauth/{provider}/login")
async def oauth_login(provider: str, request: Request):
return await oauth_manager.handle_login(request, provider)
@@ -1885,8 +1972,9 @@ async def oauth_login(provider: str, request: Request):
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
# - Email addresses are considered unique, so we fail registration if the email address is already taken
@app.get("/oauth/{provider}/callback")
async def oauth_callback(provider: str, request: Request, response: Response):
@app.get("/oauth/{provider}/login/callback")
@app.get("/oauth/{provider}/callback") # Legacy endpoint
async def oauth_login_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(request, provider, response)
@@ -0,0 +1,34 @@
"""Add reply_to_id column to message
Revision ID: a5c220713937
Revises: 38d63c18f30f
Create Date: 2025-09-27 02:24:18.058455
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a5c220713937"
down_revision: Union[str, None] = "38d63c18f30f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
"message",
sa.Column("reply_to_id", sa.Text(), nullable=True),
)
pass
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column("message", "reply_to_id")
pass
+4
View File
@@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
####################
class ChannelResponse(ChannelModel):
write_access: bool = False
class ChannelForm(BaseModel):
name: str
description: Optional[str] = None
+31 -2
View File
@@ -366,6 +366,15 @@ class ChatTable:
except Exception:
return False
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
try:
with get_db() as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
db.commit()
return True
except Exception:
return False
def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str]
) -> Optional[ChatModel]:
@@ -492,11 +501,16 @@ class ChatTable:
self,
user_id: str,
include_archived: bool = False,
include_folders: bool = False,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[ChatTitleIdResponse]:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
query = db.query(Chat).filter_by(user_id=user_id)
if not include_folders:
query = query.filter_by(folder_id=None)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived:
@@ -805,7 +819,7 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
@@ -814,6 +828,11 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
@@ -943,6 +962,16 @@ class ChatTable:
return count
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id)
query = query.filter_by(folder_id=folder_id)
count = query.count()
log.info(f"Count of chats for folder '{folder_id}': {count}")
return count
def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str
) -> bool:
+11
View File
@@ -130,6 +130,17 @@ class FilesTable:
except Exception:
return None
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
with get_db() as db:
try:
file = db.query(File).filter_by(id=id, user_id=user_id).first()
if file:
return FileModel.model_validate(file)
else:
return None
except Exception:
return None
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db:
try:
+14
View File
@@ -50,6 +50,20 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FolderMetadataResponse(BaseModel):
icon: Optional[str] = None
class FolderNameIdResponse(BaseModel):
id: str
name: str
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
created_at: int
updated_at: int
####################
# Forms
####################
+98 -13
View File
@@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, UserNameResponse
from pydantic import BaseModel, ConfigDict
@@ -43,6 +44,7 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
count: int
class MessageResponse(MessageModel):
class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None
class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
@@ -107,6 +119,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
@@ -128,19 +141,32 @@ class MessageTable:
if not message:
return None
reactions = self.get_reactions_by_message_id(id)
replies = self.get_replies_by_message_id(id)
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
return MessageResponse(
**{
reactions = self.get_reactions_by_message_id(id)
thread_replies = self.get_thread_replies_by_message_id(id)
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None,
"reply_count": len(replies),
"user": user.model_dump() if user else None,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
"latest_reply_at": (
thread_replies[0].created_at if thread_replies else None
),
"reply_count": len(thread_replies),
"reactions": reactions,
}
)
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -148,7 +174,27 @@ class MessageTable:
.order_by(Message.created_at.desc())
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
@@ -159,7 +205,7 @@ class MessageTable:
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -169,11 +215,31 @@ class MessageTable:
.limit(limit)
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
) -> list[MessageReplyToResponse]:
with get_db() as db:
message = db.get(Message, parent_id)
@@ -193,7 +259,26 @@ class MessageTable:
if len(all_messages) < limit:
all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def update_message_by_id(
self, id: str, form_data: MessageForm
+61 -8
View File
@@ -2,6 +2,7 @@ import json
import time
import uuid
from typing import Optional
from functools import lru_cache
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
@@ -110,20 +111,72 @@ class NoteTable:
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id(
self,
user_id: str,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id)
query = query.order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_permission(
self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
notes = self.get_notes(skip=skip, limit=limit)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
note
for note in notes
if note.user_id == user_id
or has_access(user_id, permission, note.access_control, user_group_ids)
]
with get_db() as db:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
# Order newest-first. We stream to keep memory usage low.
query = (
db.query(Note)
.order_by(Note.updated_at.desc())
.execution_options(stream_results=True)
.yield_per(256)
)
results: list[NoteModel] = []
n_skipped = 0
for note in query:
# Fast-pass #1: owner
if note.user_id == user_id:
permitted = True
# Fast-pass #2: public/open
elif note.access_control is None:
# Technically this should mean public access for both read and write, but we'll only do read for now
# We might want to change this behavior later
permitted = permission == "read"
else:
permitted = has_access(
user_id, permission, note.access_control, user_group_ids
)
if not permitted:
continue
# Apply skip AFTER permission filtering so it counts only accessible notes
if skip and n_skipped < skip:
n_skipped += 1
continue
results.append(NoteModel.model_validate(note))
if limit is not None and len(results) >= limit:
break
return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
@@ -176,6 +176,26 @@ class OAuthSessionTable:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_session_by_provider_and_user_id(
self, provider: str, user_id: str
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID"""
try:
with get_db() as db:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
.first()
)
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by provider and user ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
+2
View File
@@ -95,6 +95,8 @@ class ToolResponse(BaseModel):
class ToolUserResponse(ToolResponse):
user: Optional[UserResponse] = None
model_config = ConfigDict(extra="allow")
class ToolForm(BaseModel):
id: str
+7 -1
View File
@@ -127,7 +127,13 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float,
) -> dict:
try:
if not collection_result.documents[0]:
if (
not collection_result
or not hasattr(collection_result, "documents")
or not collection_result.documents
or len(collection_result.documents) == 0
or not collection_result.documents[0]
):
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []}
@@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
CHROMA_DATA_PATH,
@@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
metadatas = [process_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items]
metadatas = [process_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
@@ -3,7 +3,7 @@ from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -6,7 +6,7 @@ import json
import logging
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -22,6 +22,8 @@ from open_webui.config import (
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
MILVUS_DISKANN_MAX_DEGREE,
MILVUS_DISKANN_SEARCH_LIST_SIZE,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
elif index_type == "IVF_FLAT":
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
log.info(f"IVF_FLAT params: {index_creation_params}")
elif index_type == "DISKANN":
index_creation_params = {
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
}
log.info(f"DISKANN params: {index_creation_params}")
elif index_type in ["FLAT", "AUTOINDEX"]:
log.info(f"Using {index_type} index with no specific build-time params.")
else:
log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. "
f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
f"Milvus will use its default for the collection if this type is not directly supported for index creation."
)
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
@@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
)
return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying
@@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
"data",
"metadata",
],
limit=limit, # Pass the limit directly; None means no limit.
limit=limit, # Pass the limit directly; -1 means no limit.
)
while True:
@@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
)
# Using query with a trivial filter to get all items.
# This will use the paginated query logic.
return self.query(collection_name=collection_name, filter={}, limit=None)
return self.query(collection_name=collection_name, filter={}, limit=-1)
def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created.
@@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
}
for item in items
],
@@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
}
for item in items
],
@@ -0,0 +1,282 @@
import logging
from typing import Optional, Tuple, List, Dict, Any
from open_webui.config import (
MILVUS_URI,
MILVUS_TOKEN,
MILVUS_DB,
MILVUS_COLLECTION_PREFIX,
MILVUS_INDEX_TYPE,
MILVUS_METRIC_TYPE,
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from pymilvus import (
connections,
utility,
Collection,
CollectionSchema,
FieldSchema,
DataType,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
RESOURCE_ID_FIELD = "resource_id"
class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
connections.connect(
alias="default",
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
self.shared_collections = [
self.MEMORY_COLLECTION,
self.KNOWLEDGE_COLLECTION,
self.FILE_COLLECTION,
self.WEB_SEARCH_COLLECTION,
self.HASH_BASED_COLLECTION,
]
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
"""
Maps the traditional collection name to multi-tenant collection and resource ID.
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
"""
resource_id = collection_name
if collection_name.startswith("user-memory-"):
return self.MEMORY_COLLECTION, resource_id
elif collection_name.startswith("file-"):
return self.FILE_COLLECTION, resource_id
elif collection_name.startswith("web-search-"):
return self.WEB_SEARCH_COLLECTION, resource_id
elif len(collection_name) == 63 and all(
c in "0123456789abcdef" for c in collection_name
):
return self.HASH_BASED_COLLECTION, resource_id
else:
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
fields = [
FieldSchema(
name="id",
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="metadata", dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
collection = Collection(mt_collection_name, schema)
index_params = {
"metric_type": MILVUS_METRIC_TYPE,
"index_type": MILVUS_INDEX_TYPE,
"params": {},
}
if MILVUS_INDEX_TYPE == "HNSW":
index_params["params"] = {
"M": MILVUS_HNSW_M,
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
collection.create_index("vector", index_params)
collection.create_index(RESOURCE_ID_FIELD)
log.info(f"Created shared collection: {mt_collection_name}")
return collection
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not utility.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return False
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
if not items:
return
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
dimension = len(items[0]["vector"])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = [
{
"id": item["id"],
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
RESOURCE_ID_FIELD: resource_id,
}
for item in items
]
collection.insert(entities)
collection.flush()
def search(
self, collection_name: str, vectors: List[List[float]], limit: int
) -> Optional[SearchResult]:
if not vectors:
return None
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
results = collection.search(
data=vectors,
anns_field="vector",
param=search_params,
limit=limit,
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=["id", "text", "metadata"],
)
ids, documents, metadatas, distances = [], [], [], []
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
batch_ids.append(hit.entity.get("id"))
batch_docs.append(hit.entity.get("text"))
batch_metadatas.append(hit.entity.get("metadata"))
batch_dists.append(hit.distance)
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
distances.append(batch_dists)
return SearchResult(
ids=ids, documents=documents, metadatas=metadatas, distances=distances
)
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
# Build expression
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
expr.append(f"id in [{id_list_str}]")
if filter:
for key, value in filter.items():
expr.append(f"metadata['{key}'] == '{value}'")
collection.delete(" and ".join(expr))
def reset(self):
for collection_name in self.shared_collections:
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
for key, value in filter.items():
if isinstance(value, str):
expr.append(f"metadata['{key}'] == '{value}'")
else:
expr.append(f"metadata['{key}'] == {value}")
results = collection.query(
expr=" and ".join(expr),
output_fields=["id", "text", "metadata"],
limit=limit,
)
ids = [res["id"] for res in results]
documents = [res["text"] for res in results]
metadatas = [res["metadata"] for res in results]
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def get(self, collection_name: str) -> Optional[GetResult]:
return self.query(collection_name, filter={}, limit=None)
def insert(self, collection_name: str, items: List[VectorItem]):
return self.upsert(collection_name, items)
@@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
"metadata": stringify_metadata(item["metadata"]),
"metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
@@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -265,7 +265,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=stringify_metadata(item["metadata"]),
vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@@ -323,7 +323,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = stringify_metadata(item["metadata"])
existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@@ -333,7 +333,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=stringify_metadata(item["metadata"]),
vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
@@ -32,7 +32,7 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
@@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
"metadata": stringify_metadata(metadata),
"metadata": process_metadata(metadata),
}
points.append(point)
return points
@@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
Returns:
tuple: (collection_name, tenant_id)
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
"""
# Check for user memory collections
tenant_id = collection_name
@@ -1,4 +1,4 @@
from open_webui.retrieval.vector.utils import stringify_metadata
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
@@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"]
# Convert metadata to string format for consistency
metadata = stringify_metadata(metadata)
metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"])
+14 -3
View File
@@ -1,6 +1,10 @@
from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
from open_webui.config import (
VECTOR_DB,
ENABLE_QDRANT_MULTITENANCY_MODE,
ENABLE_MILVUS_MULTITENANCY_MODE,
)
class Vector:
@@ -12,9 +16,16 @@ class Vector:
"""
match vector_type:
case VectorType.MILVUS:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
if ENABLE_MILVUS_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
MilvusClient,
)
return MilvusClient()
return MilvusClient()
else:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
return MilvusClient()
case VectorType.QDRANT:
if ENABLE_QDRANT_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
+15 -1
View File
@@ -1,10 +1,24 @@
from datetime import datetime
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
def stringify_metadata(
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
}
return metadata
def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
for key, value in metadata.items():
# Remove large fields
if key in KEYS_TO_EXCLUDE:
del metadata[key]
# Convert non-serializable fields to strings
if (
isinstance(value, datetime)
or isinstance(value, list)
@@ -0,0 +1,51 @@
import logging
from dataclasses import dataclass
from typing import Optional
import requests
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_ollama_cloud(
url: str,
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Ollama Search API key
query (str): The query to search for
count (int): Number of results to return
filter_list (Optional[list[str]]): List of domains to filter results by
"""
log.info(f"Searching with Ollama for query: {query}")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"query": query, "max_results": count}
try:
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
log.info(f"Found {len(results)} results")
return [
SearchResult(
link=result.get("url", ""),
title=result.get("title", ""),
snippet=result.get("content", ""),
)
for result in results
]
except Exception as e:
log.error(f"Error searching Ollama: {e}")
return []
@@ -0,0 +1,64 @@
import logging
from typing import Optional, Literal
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_perplexity_search(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Perplexity API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Perplexity API key
query (str): The query to search for
count (int): Maximum number of results to return
filter_list (Optional[list[str]]): List of domains to filter results
"""
# Handle PersistentConfig object
if hasattr(api_key, "__str__"):
api_key = str(api_key)
try:
url = "https://api.perplexity.ai/search"
# Create payload for the API call
payload = {
"query": query,
"max_results": count,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Make the API request
response = requests.request("POST", url, json=payload, headers=headers)
# Parse the JSON response
json_response = response.json()
# Extract citations from the response
results = json_response.get("results", [])
return [
SearchResult(
link=result["url"], title=result["title"], snippet=result["snippet"]
)
for result in results
]
except Exception as e:
log.error(f"Error searching with Perplexity Search API: {e}")
return []
+82 -67
View File
@@ -10,7 +10,13 @@ from pydantic import BaseModel
from open_webui.socket.main import sio, get_user_ids_from_room
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
from open_webui.models.groups import Groups
from open_webui.models.channels import (
Channels,
ChannelModel,
ChannelForm,
ChannelResponse,
)
from open_webui.models.messages import (
Messages,
MessageModel,
@@ -80,7 +86,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
############################
@router.get("/{id}", response_model=Optional[ChannelModel])
@router.get("/{id}", response_model=Optional[ChannelResponse])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
channel = Channels.get_channel_by_id(id)
if not channel:
@@ -95,7 +101,16 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
return ChannelModel(**channel.model_dump())
write_access = has_access(
user.id, type="write", access_control=channel.access_control, strict=False
)
return ChannelResponse(
**{
**channel.model_dump(),
"write_access": write_access or user.role == "admin",
}
)
############################
@@ -152,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
class MessageUserResponse(MessageResponse):
user: UserNameResponse
pass
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
@@ -181,15 +196,17 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None
thread_replies = Messages.get_thread_replies_by_message_id(message.id)
latest_thread_reply_at = (
thread_replies[0].created_at if thread_replies else None
)
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": len(replies),
"latest_reply_at": latest_reply_at,
"reply_count": len(thread_replies),
"latest_reply_at": latest_thread_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
@@ -238,12 +255,26 @@ async def model_response_handler(request, channel, message, user):
mentions = extract_mentions(message.content)
message_content = replace_mentions(message.content)
model_mentions = {}
# check if the message is a reply to a message sent by a model
if (
message.reply_to_message
and message.reply_to_message.meta
and message.reply_to_message.meta.get("model_id", None)
):
model_id = message.reply_to_message.meta.get("model_id", None)
model_mentions[model_id] = {"id": model_id, "id_type": "M"}
# check if any of the mentions are models
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
for mention in mentions:
if mention["id_type"] == "M" and mention["id"] not in model_mentions:
model_mentions[mention["id"]] = mention
if not model_mentions:
return False
for mention in model_mentions:
for mention in model_mentions.values():
model_id = mention["id"]
model = MODELS.get(model_id, None)
@@ -275,6 +306,7 @@ async def model_response_handler(request, channel, message, user):
)
thread_history = []
images = []
message_users = {}
for thread_message in thread_messages:
@@ -303,24 +335,44 @@ async def model_response_handler(request, channel, message, user):
f"{username}: {replace_mentions(thread_message.content)}"
)
thread_message_files = thread_message.data.get("files", [])
for file in thread_message_files:
if file.get("type", "") == "image":
images.append(file.get("url", ""))
system_message = {
"role": "system",
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
"content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
+ (
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
if thread_history
else ""
),
}
content = f"{user.name if user else 'User'}: {message_content}"
if images:
content = [
{
"type": "text",
"text": content,
},
*[
{
"type": "image_url",
"image_url": {
"url": image,
},
}
for image in images
],
]
form_data = {
"model": model_id,
"messages": [
system_message,
{
"role": "user",
"content": f"{user.name if user else 'User'}: {message_content}",
},
{"role": "user", "content": content},
],
"stream": False,
}
@@ -362,7 +414,7 @@ async def new_message_handler(
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -370,24 +422,14 @@ async def new_message_handler(
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
if message:
message = Messages.get_message_by_id(message.id)
event_data = {
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message",
"data": MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
"data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -411,23 +453,16 @@ async def new_message_handler(
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
"data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return MessageModel(**message.model_dump()), channel
return message, channel
else:
raise Exception("Error creating message")
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -615,14 +650,7 @@ async def update_message_by_id(
"message_id": message.id,
"data": {
"type": "message:update",
"data": MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
"data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -658,7 +686,7 @@ async def add_reaction_to_message(
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -688,9 +716,6 @@ async def add_reaction_to_message(
"type": "message:reaction:add",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
@@ -724,7 +749,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -757,9 +782,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
@@ -806,7 +828,9 @@ async def delete_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control)
and not has_access(
user.id, type="write", access_control=channel.access_control, strict=False
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -844,16 +868,7 @@ async def delete_message_by_id(
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
"data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
+39 -3
View File
@@ -37,7 +37,9 @@ router = APIRouter()
@router.get("/", response_model=list[ChatTitleIdResponse])
@router.get("/list", response_model=list[ChatTitleIdResponse])
def get_session_user_chat_list(
user=Depends(get_verified_user), page: Optional[int] = None
user=Depends(get_verified_user),
page: Optional[int] = None,
include_folders: Optional[bool] = False,
):
try:
if page is not None:
@@ -45,10 +47,12 @@ def get_session_user_chat_list(
skip = (page - 1) * limit
return Chats.get_chat_title_id_list_by_user_id(
user.id, skip=skip, limit=limit
user.id, include_folders=include_folders, skip=skip, limit=limit
)
else:
return Chats.get_chat_title_id_list_by_user_id(user.id)
return Chats.get_chat_title_id_list_by_user_id(
user.id, include_folders=include_folders
)
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -214,6 +218,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
]
@router.get("/folder/{folder_id}/list")
async def get_chat_list_by_folder_id(
folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
):
try:
limit = 60
skip = (page - 1) * limit
return [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder_id, user.id, skip=skip, limit=limit
)
]
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetPinnedChats
############################
@@ -335,6 +361,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
return Chats.archive_all_chats_by_user_id(user.id)
############################
# UnarchiveAllChats
############################
@router.post("/unarchive/all", response_model=bool)
async def unarchive_all_chats(user=Depends(get_verified_user)):
return Chats.unarchive_all_chats_by_user_id(user.id)
############################
# GetSharedChatById
############################
+170 -8
View File
@@ -1,5 +1,7 @@
import logging
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel, ConfigDict
import aiohttp
from typing import Optional
@@ -12,10 +14,24 @@ from open_webui.utils.tools import (
get_tool_server_url,
set_tool_servers,
)
from open_webui.utils.mcp.client import MCPClient
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.oauth import (
get_discovery_urls,
get_oauth_client_info_with_dynamic_client_registration,
encrypt_data,
decrypt_data,
OAuthClientInformationFull,
)
from mcp.shared.auth import OAuthMetadata
router = APIRouter()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
############################
# ImportConfig
@@ -79,6 +95,43 @@ async def set_connections_config(
}
class OAuthClientRegistrationForm(BaseModel):
url: str
client_id: str
client_name: Optional[str] = None
@router.post("/oauth/clients/register")
async def register_oauth_client(
request: Request,
form_data: OAuthClientRegistrationForm,
type: Optional[str] = None,
user=Depends(get_admin_user),
):
try:
oauth_client_id = form_data.client_id
if type:
oauth_client_id = f"{type}:{form_data.client_id}"
oauth_client_info = (
await get_oauth_client_info_with_dynamic_client_registration(
request, oauth_client_id, form_data.url
)
)
return {
"status": True,
"oauth_client_info": encrypt_data(
oauth_client_info.model_dump(mode="json")
),
}
except Exception as e:
log.debug(f"Failed to register OAuth client: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to register OAuth client",
)
############################
# ToolServers Config
############################
@@ -87,6 +140,7 @@ async def set_connections_config(
class ToolServerConnection(BaseModel):
url: str
path: str
type: Optional[str] = "openapi" # openapi, mcp
auth_type: Optional[str]
key: Optional[str]
config: Optional[dict]
@@ -114,8 +168,29 @@ async def set_tool_servers_config(
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
]
await set_tool_servers(request)
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
server_type = connection.get("type", "openapi")
if server_type == "mcp":
server_id = connection.get("info", {}).get("id")
auth_type = connection.get("auth_type", "none")
if auth_type == "oauth_2.1" and server_id:
try:
oauth_client_info = connection.get("info", {}).get(
"oauth_client_info", ""
)
oauth_client_info = decrypt_data(oauth_client_info)
await request.app.state.oauth_client_manager.add_client(
f"{server_type}:{server_id}",
OAuthClientInformationFull(**oauth_client_info),
)
except Exception as e:
log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
continue
return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
}
@@ -129,19 +204,106 @@ async def verify_tool_servers_config(
Verify the connection to the tool server.
"""
try:
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
)
async with aiohttp.ClientSession() as session:
async with session.get(
discovery_urls[0]
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status == 200:
try:
oauth_server_metadata = (
OAuthMetadata.model_validate(
await oauth_server_metadata_response.json()
)
)
return {
"status": True,
"oauth_server_metadata": oauth_server_metadata.model_dump(
mode="json"
),
}
except Exception as e:
log.info(
f"Failed to parse OAuth 2.1 discovery document: {e}"
)
raise HTTPException(
status_code=400,
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}",
)
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
raise HTTPException(
status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
)
else:
try:
client = MCPClient()
headers = None
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
pass
if token:
headers = {"Authorization": f"Bearer {token}"}
await client.connect(form_data.url, headers=headers)
specs = await client.list_tool_specs()
return {
"status": True,
"specs": specs,
}
except Exception as e:
log.debug(f"Failed to create MCP client: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to create MCP client",
)
finally:
if client:
await client.disconnect()
else: # openapi
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
pass
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
except HTTPException as e:
raise e
except Exception as e:
log.debug(f"Failed to connect to the tool server: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to connect to the tool server: {str(e)}",
detail=f"Failed to connect to the tool server",
)
+10 -17
View File
@@ -12,6 +12,7 @@ from open_webui.models.folders import (
FolderForm,
FolderUpdateForm,
FolderModel,
FolderNameIdResponse,
Folders,
)
from open_webui.models.chats import Chats
@@ -44,7 +45,7 @@ router = APIRouter()
############################
@router.get("/", response_model=list[FolderModel])
@router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
@@ -76,14 +77,6 @@ async def get_folders(user=Depends(get_verified_user)):
return [
{
**folder.model_dump(),
"items": {
"chats": [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder.id, user.id
)
]
},
}
for folder in folders
]
@@ -262,15 +255,15 @@ async def update_folder_is_expanded_by_id(
async def delete_folder_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
+7 -4
View File
@@ -431,8 +431,10 @@ async def update_function_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
Functions.update_function_valves_by_id(id, valves.model_dump())
return valves.model_dump()
valves_dict = valves.model_dump(exclude_unset=True)
Functions.update_function_valves_by_id(id, valves_dict)
return valves_dict
except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}")
raise HTTPException(
@@ -514,10 +516,11 @@ async def update_function_user_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id(
id, user.id, user_valves.model_dump()
id, user.id, user_valves_dict
)
return user_valves.model_dump()
return user_valves_dict
except Exception as e:
log.exception(f"Error updating function user valves by id {id}: {e}")
raise HTTPException(
+3 -7
View File
@@ -514,6 +514,7 @@ async def image_generations(
size = form_data.size
width, height = tuple(map(int, size.split("x")))
model = get_image_model(request)
r = None
try:
@@ -531,11 +532,7 @@ async def image_generations(
headers["X-OpenWebUI-User-Role"] = user.role
data = {
"model": (
request.app.state.config.IMAGE_GENERATION_MODEL
if request.app.state.config.IMAGE_GENERATION_MODEL != ""
else "dall-e-2"
),
"model": model,
"prompt": form_data.prompt,
"n": form_data.n,
"size": (
@@ -584,7 +581,6 @@ async def image_generations(
headers["Content-Type"] = "application/json"
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
model = get_image_model(request)
data = {
"instances": {"prompt": form_data.prompt},
"parameters": {
@@ -640,7 +636,7 @@ async def image_generations(
}
)
res = await comfyui_generate_image(
request.app.state.config.IMAGE_GENERATION_MODEL,
model,
form_data,
user.id,
request.app.state.config.COMFYUI_BASE_URL,
+57 -1
View File
@@ -1,6 +1,9 @@
from typing import Optional
import io
import base64
import json
import asyncio
import logging
from open_webui.models.models import (
ModelForm,
@@ -12,7 +15,14 @@ from open_webui.models.models import (
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
status,
Response,
)
from fastapi.responses import FileResponse, StreamingResponse
@@ -20,6 +30,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
log = logging.getLogger(__name__)
router = APIRouter()
@@ -93,6 +105,50 @@ async def export_models(user=Depends(get_admin_user)):
return Models.get_models()
############################
# ImportModels
############################
class ModelsImportForm(BaseModel):
models: list[dict]
@router.post("/import", response_model=bool)
async def import_models(
user: str = Depends(get_admin_user), form_data: ModelsImportForm = (...)
):
try:
data = form_data.models
if isinstance(data, list):
for model_data in data:
# Here, you can add logic to validate model_data if needed
model_id = model_data.get("id")
if model_id:
existing_model = Models.get_model_by_id(model_id)
if existing_model:
# Update existing model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
updated_model = ModelForm(
**{**existing_model.model_dump(), **model_data}
)
Models.update_model_by_id(model_id, updated_model)
else:
# Insert new model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
new_model = ModelForm(**model_data)
Models.insert_new_model(user_id=user.id, form_data=new_model)
return True
else:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except Exception as e:
log.exception(e)
raise HTTPException(status_code=500, detail=str(e))
############################
# SyncModels
############################
+16 -2
View File
@@ -48,7 +48,7 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "write")
for note in Notes.get_notes_by_permission(user.id, "write")
]
return notes
@@ -81,7 +81,9 @@ async def get_note_list(
notes = [
NoteTitleIdResponse(**note.model_dump())
for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
for note in Notes.get_notes_by_permission(
user.id, "write", skip=skip, limit=limit
)
]
return notes
@@ -178,6 +180,18 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
try:
note = Notes.update_note_by_id(id, form_data)
await sio.emit(
+18 -16
View File
@@ -1694,25 +1694,27 @@ async def download_file_stream(
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done:
file.seek(0)
chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
file.seek(0)
file.close()
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
response = requests.post(url, data=file)
with open(file_path, "rb") as file:
chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
if response.ok:
res = {
"done": done,
"blob": f"sha256:{hashed}",
"name": file_name,
}
os.remove(file_path)
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
with requests.Session() as session:
response = session.post(url, data=file, timeout=30)
yield f"data: {json.dumps(res)}\n\n"
else:
raise "Ollama: Could not create blob, Please try again."
if response.ok:
res = {
"done": done,
"blob": f"sha256:{hashed}",
"name": file_name,
}
os.remove(file_path)
yield f"data: {json.dumps(res)}\n\n"
else:
raise "Ollama: Could not create blob, Please try again."
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
+10 -8
View File
@@ -121,7 +121,7 @@ def openai_reasoning_model_handler(payload):
return payload
def get_headers_and_cookies(
async def get_headers_and_cookies(
request: Request,
url,
key=None,
@@ -174,7 +174,7 @@ def get_headers_and_cookies(
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token(
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
@@ -305,7 +305,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
headers, cookies = get_headers_and_cookies(
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
@@ -570,7 +570,7 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers, cookies = get_headers_and_cookies(
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
@@ -656,7 +656,7 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers, cookies = get_headers_and_cookies(
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
@@ -901,7 +901,7 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
headers, cookies = get_headers_and_cookies(
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, metadata, user=user
)
@@ -1010,7 +1010,9 @@ async def embeddings(request: Request, form_data: dict, user):
session = None
streaming = False
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
try:
session = aiohttp.ClientSession(trust_env=True)
r = await session.request(
@@ -1080,7 +1082,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False
try:
headers, cookies = get_headers_and_cookies(
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
+225 -181
View File
@@ -45,6 +45,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
# Web search engines
from open_webui.retrieval.web.main import SearchResult
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.web.ollama import search_ollama_cloud
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.brave import search_brave
from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.mojeek import search_mojeek
@@ -76,6 +78,7 @@ from open_webui.retrieval.utils import (
query_doc,
query_doc_with_hybrid_search,
)
from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.utils.misc import (
calculate_sha256_string,
)
@@ -469,6 +472,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@@ -525,6 +529,7 @@ class WebConfig(BaseModel):
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None
@@ -988,6 +993,9 @@ async def update_rag_config(
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
)
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
)
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
@@ -1139,6 +1147,7 @@ async def update_rag_config(
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@@ -1407,59 +1416,35 @@ def process_file(
form_data: ProcessFileForm,
user=Depends(get_verified_user),
):
try:
if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id)
else:
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
collection_name = form_data.collection_name
if file:
try:
if collection_name is None:
collection_name = f"file-{file.id}"
collection_name = form_data.collection_name
if form_data.content:
# Update the content in the file
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
if collection_name is None:
collection_name = f"file-{file.id}"
try:
# /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
except:
# Audio file upload pipeline
pass
if form_data.content:
# Update the content in the file
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
docs = [
Document(
page_content=form_data.content.replace("<br/>", "\n"),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = form_data.content
elif form_data.collection_name:
# Check if the file has already been processed and save the content
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
result = VECTOR_DB_CLIENT.query(
collection_name=f"file-{file.id}", filter={"file_id": file.id}
)
if result is not None and len(result.ids[0]) > 0:
docs = [
Document(
page_content=result.documents[0][idx],
metadata=result.metadatas[0][idx],
try:
# /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(
collection_name=f"file-{file.id}"
)
for idx, id in enumerate(result.ids[0])
]
else:
except:
# Audio file upload pipeline
pass
docs = [
Document(
page_content=file.data.get("content", ""),
page_content=form_data.content.replace("<br/>", "\n"),
metadata={
**file.meta,
"name": file.filename,
@@ -1470,149 +1455,190 @@ def process_file(
)
]
text_content = file.data.get("content", "")
else:
# Process the file and save the content
# Usage: /files/
file_path = file.path
if file_path:
file_path = Storage.get_file(file_path)
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
},
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
text_content = form_data.content
elif form_data.collection_name:
# Check if the file has already been processed and save the content
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
result = VECTOR_DB_CLIENT.query(
collection_name=f"file-{file.id}", filter={"file_id": file.id}
)
docs = [
Document(
page_content=doc.page_content,
metadata={
**doc.metadata,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
for doc in docs
]
else:
docs = [
Document(
page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id(
file.id,
{"content": text_content},
)
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
else:
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
if result is not None and len(result.ids[0]) > 0:
docs = [
Document(
page_content=result.documents[0][idx],
metadata=result.metadatas[0][idx],
)
for idx, id in enumerate(result.ids[0])
]
else:
raise Exception("Error saving document to vector database")
except Exception as e:
raise e
docs = [
Document(
page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
except Exception as e:
log.exception(e)
if "No pandoc was found" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
text_content = file.data.get("content", "")
else:
# Process the file and save the content
# Usage: /files/
file_path = file.path
if file_path:
file_path = Storage.get_file(file_path)
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
},
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
)
docs = [
Document(
page_content=doc.page_content,
metadata={
**filter_metadata(doc.metadata),
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
for doc in docs
]
else:
docs = [
Document(
page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id(
file.id,
{"content": text_content},
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
else:
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
else:
raise Exception("Error saving document to vector database")
except Exception as e:
raise e
except Exception as e:
log.exception(e)
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
)
if "No pandoc was found" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
class ProcessTextForm(BaseModel):
name: str
@@ -1769,7 +1795,25 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
"""
# TODO: add playwright to search the web
if engine == "searxng":
if engine == "ollama_cloud":
return search_ollama_cloud(
"https://ollama.com",
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "perplexity_search":
if request.app.state.config.PERPLEXITY_API_KEY:
return search_perplexity_search(
request.app.state.config.PERPLEXITY_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No PERPLEXITY_API_KEY found in environment variables")
elif engine == "searxng":
if request.app.state.config.SEARXNG_QUERY_URL:
return search_searxng(
request.app.state.config.SEARXNG_QUERY_URL,
+78 -6
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.tools import (
ToolForm,
ToolModel,
@@ -16,7 +17,11 @@ from open_webui.models.tools import (
ToolUserResponse,
Tools,
)
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
get_tool_module_from_cache,
)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
@@ -34,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
def get_tool_module(request, tool_id, load_from_db=True):
"""
Get the tool module by its ID.
"""
tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
return tool_module
############################
# GetTools
############################
@@ -41,8 +54,21 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = Tools.get_tools()
tools = []
# Local Tools
for tool in Tools.get_tools():
tool_module = get_tool_module(request, tool.id)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": hasattr(tool_module, "UserValves"),
}
)
)
# OpenAPI Tool Servers
for server in await get_tool_servers(request):
tools.append(
ToolUserResponse(
@@ -68,6 +94,50 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
# MCP Tool Servers
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server.get("type", "openapi") == "mcp":
server_id = server.get("info", {}).get("id")
auth_type = server.get("auth_type", "none")
session_token = None
if auth_type == "oauth_2.1":
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
session_token = (
await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
)
tools.append(
ToolUserResponse(
**{
"id": f"server:mcp:{server.get('info', {}).get('id')}",
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
"name": server.get("info", {}).get("name", "MCP Tool Server"),
"meta": {
"description": server.get("info", {}).get(
"description", ""
),
},
"access_control": server.get("config", {}).get(
"access_control", None
),
"updated_at": int(time.time()),
"created_at": int(time.time()),
**(
{
"authenticated": session_token is not None,
}
if auth_type == "oauth_2.1"
else {}
),
}
)
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools
return tools
@@ -462,8 +532,9 @@ async def update_tools_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
Tools.update_tool_valves_by_id(id, valves.model_dump())
return valves.model_dump()
valves_dict = valves.model_dump(exclude_unset=True)
Tools.update_tool_valves_by_id(id, valves_dict)
return valves_dict
except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException(
@@ -538,10 +609,11 @@ async def update_tools_user_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id(
id, user.id, user_valves.model_dump()
id, user.id, user_valves_dict
)
return user_valves.model_dump()
return user_valves_dict
except Exception as e:
log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException(
+1
View File
@@ -157,6 +157,7 @@ class SharingPermissions(BaseModel):
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
public_notes: bool = True
class ChatPermissions(BaseModel):
+17
View File
@@ -705,6 +705,23 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "embeds":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
embeds = event_data.get("data", {}).get("embeds", [])
embeds.extend(message.get("embeds", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"embeds": embeds,
},
)
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
+5 -1
View File
@@ -110,9 +110,13 @@ def has_access(
type: str = "write",
access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
strict: bool = True,
) -> bool:
if access_control is None:
return type == "read"
if strict:
return type == "read"
else:
return True
if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id)
+97
View File
@@ -0,0 +1,97 @@
from open_webui.routers.images import (
load_b64_image_data,
upload_image,
)
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
UploadFile,
)
from open_webui.routers.files import upload_file_handler
import mimetypes
import base64
import io
def get_image_url_from_base64(request, base64_image_string, metadata, user):
if "data:image/png;base64" in base64_image_string:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = load_b64_image_data(base64_image_string)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
return image_url
return None
def load_b64_audio_data(b64_str):
try:
if "," in b64_str:
header, b64_data = b64_str.split(",", 1)
else:
b64_data = b64_str
header = "data:audio/wav;base64"
audio_data = base64.b64decode(b64_data)
content_type = (
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
)
return audio_data, content_type
except Exception as e:
print(f"Error decoding base64 audio data: {e}")
return None, None
def upload_audio(request, audio_data, content_type, metadata, user):
audio_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(audio_data),
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
headers={
"content-type": content_type,
},
)
file_item = upload_file_handler(
request,
file=file,
metadata=metadata,
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
if "data:audio/wav;base64" in base64_audio_string:
audio_url = ""
# Extract base64 audio data from the line
audio_data, content_type = load_b64_audio_data(base64_audio_string)
if audio_data is not None:
audio_url = upload_audio(
request,
audio_data,
content_type,
metadata,
user,
)
return audio_url
return None
def get_file_url_from_base64(request, base64_file_string, metadata, user):
if "data:image/png;base64" in base64_file_string:
return get_image_url_from_base64(request, base64_file_string, metadata, user)
elif "data:audio/wav;base64" in base64_file_string:
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
return None
+110
View File
@@ -0,0 +1,110 @@
import asyncio
from typing import Optional
from contextlib import AsyncExitStack
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
class MCPClient:
def __init__(self):
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
async def connect(self, url: str, headers: Optional[dict] = None):
try:
self._streams_context = streamablehttp_client(url, headers=headers)
transport = await self.exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream
) # pylint: disable=W0201
self.session = await self.exit_stack.enter_async_context(
self._session_context
)
await self.session.initialize()
except Exception as e:
await self.disconnect()
raise e
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_tools()
tools = result.tools
tool_specs = []
for tool in tools:
name = tool.name
description = tool.description
inputSchema = tool.inputSchema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, "outputSchema", None)
tool_specs.append(
{"name": name, "description": description, "parameters": inputSchema}
)
return tool_specs
async def call_tool(
self, function_name: str, function_args: dict
) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.call_tool(function_name, function_args)
if not result:
raise Exception("No result returned from MCP tool call.")
result_dict = result.model_dump(mode="json")
result_content = result_dict.get("content", {})
if result.isError:
raise Exception(result_content)
else:
return result_content
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_resources(cursor=cursor)
if not result:
raise Exception("No result returned from MCP list_resources call.")
result_dict = result.model_dump()
resources = result_dict.get("resources", [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.read_resource(uri)
if not result:
raise Exception("No result returned from MCP read_resource call.")
result_dict = result.model_dump()
return result_dict
async def disconnect(self):
# Clean up and close the session
await self.exit_stack.aclose()
async def __aenter__(self):
await self.exit_stack.__aenter__()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
await self.disconnect()
+415 -125
View File
@@ -20,9 +20,11 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException
from fastapi.responses import HTMLResponse
from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.chats import Chats
from open_webui.models.folders import Folders
from open_webui.models.users import Users
@@ -52,6 +54,11 @@ from open_webui.routers.pipelines import (
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook
from open_webui.utils.files import (
get_audio_url_from_base64,
get_file_url_from_base64,
get_image_url_from_base64,
)
from open_webui.models.users import UserModel
@@ -86,6 +93,7 @@ from open_webui.utils.filter import (
)
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body
from open_webui.utils.mcp.client import MCPClient
from open_webui.config import (
@@ -125,6 +133,149 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool=False,
metadata=None,
user=None,
):
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get("Content-Disposition", "")
if "inline" in content_disposition:
content = tool_result.body.decode("utf-8")
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (tool_type == "external" and isinstance(tool_result, tuple)) or (
direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
try:
if not isinstance(tool_response_headers, dict):
tool_response_headers = dict(tool_response_headers)
except Exception as e:
tool_response_headers = {}
log.debug(e)
if tool_response_headers and isinstance(tool_response_headers, dict):
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get("content-disposition", ""),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
tool_result_files = []
if isinstance(tool_result, list):
if tool_type == "mcp": # MCP
tool_response = []
for item in tool_result:
if isinstance(item, dict):
if item.get("type") == "text":
text = item.get("text", "")
if isinstance(text, str):
try:
text = json.loads(text)
except json.JSONDecodeError:
pass
tool_response.append(text)
elif item.get("type") in ["image", "audio"]:
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get("chat_id", None),
"message_id": metadata.get("message_id", None),
"session_id": metadata.get("session_id", None),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result = tool_response[0] if len(tool_response) == 1 else tool_response
else: # OpenAPI
for item in tool_result:
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if isinstance(tool_result, list):
tool_result = {"results": tool_result}
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
return tool_result, tool_result_files, tool_result_embeds
async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
@@ -144,12 +295,14 @@ async def chat_completion_tools_handler(
def get_tools_function_calling_payload(messages, task_model_id, content):
user_message = get_last_user_message(messages)
history = "\n".join(
recent_messages = messages[-4:] if len(messages) > 4 else messages
chat_history = "\n".join(
f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
for message in messages[::-1][:4]
for message in recent_messages
)
prompt = f"History:\n{history}\nQuery: {user_message}"
prompt = f"History:\n{chat_history}\nQuery: {user_message}"
return {
"model": task_model_id,
@@ -162,6 +315,7 @@ async def chat_completion_tools_handler(
}
event_caller = extra_params["__event_call__"]
event_emitter = extra_params["__event_emitter__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id(
@@ -216,8 +370,14 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {})
tool = None
tool_type = ""
direct_tool = False
try:
tool = tools[tool_function_name]
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
spec = tool.get("spec", {})
allowed_params = (
@@ -249,18 +409,46 @@ async def chat_completion_tools_handler(
except Exception as e:
tool_result = str(e)
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result.remove(item)
tool_result, tool_result_files, tool_result_embeds = (
process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool,
metadata,
user,
)
)
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2)
if event_emitter:
if tool_result_files:
await event_emitter(
{
"type": "files",
"data": {
"files": tool_result_files,
},
}
)
if isinstance(tool_result, str):
if tool_result_embeds:
await event_emitter(
{
"type": "embeds",
"data": {
"embeds": tool_result_embeds,
},
}
)
print(
f"Tool {tool_function_name} result: {tool_result}",
tool_result_files,
tool_result_embeds,
)
if tool_result:
tool = tools[tool_function_name]
tool_id = tool.get("tool_id", "")
@@ -274,18 +462,19 @@ async def chat_completion_tools_handler(
sources.append(
{
"source": {
"name": (f"TOOL:{tool_name}"),
"name": (f"{tool_name}"),
},
"document": [tool_result],
"document": [str(tool_result)],
"metadata": [
{
"source": (f"TOOL:{tool_name}"),
"source": (f"{tool_name}"),
"parameters": tool_function_params,
}
],
"tool_result": True,
}
)
# Citation is not enabled for this tool
body["messages"] = add_or_update_user_message(
f"\nTool `{tool_name}` Output: {tool_result}",
@@ -631,48 +820,53 @@ async def chat_completion_files_handler(
sources = []
if files := body.get("metadata", {}).get("files", None):
# Check if all files are in full context mode
all_full_context = all(item.get("context") == "full" for item in files)
queries = []
try:
queries_response = await generate_queries(
request,
{
"model": body["model"],
"messages": body["messages"],
"type": "retrieval",
},
user,
)
queries_response = queries_response["choices"][0]["message"]["content"]
if not all_full_context:
try:
bracket_start = queries_response.find("{")
bracket_end = queries_response.rfind("}") + 1
queries_response = await generate_queries(
request,
{
"model": body["model"],
"messages": body["messages"],
"type": "retrieval",
},
user,
)
queries_response = queries_response["choices"][0]["message"]["content"]
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
try:
bracket_start = queries_response.find("{")
bracket_end = queries_response.rfind("}") + 1
queries_response = queries_response[bracket_start:bracket_end]
queries_response = json.loads(queries_response)
except Exception as e:
queries_response = {"queries": [queries_response]}
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
queries = queries_response.get("queries", [])
except:
pass
queries_response = queries_response[bracket_start:bracket_end]
queries_response = json.loads(queries_response)
except Exception as e:
queries_response = {"queries": [queries_response]}
queries = queries_response.get("queries", [])
except:
pass
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
await __event_emitter__(
{
"type": "status",
"data": {
"action": "queries_generated",
"queries": queries,
"done": False,
},
}
)
if not all_full_context:
await __event_emitter__(
{
"type": "status",
"data": {
"action": "queries_generated",
"queries": queries,
"done": False,
},
}
)
try:
# Offload get_sources_from_items to a separate thread
@@ -701,7 +895,8 @@ async def chat_completion_files_handler(
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=request.app.state.config.RAG_FULL_CONTEXT,
full_context=all_full_context
or request.app.state.config.RAG_FULL_CONTEXT,
user=user,
),
)
@@ -818,7 +1013,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token(
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
@@ -987,14 +1182,113 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
tool_servers = metadata.get("tool_servers", None)
direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}")
log.debug(f"{direct_tool_servers=}")
tools_dict = {}
mcp_clients = {}
mcp_tools_dict = {}
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id") == server_id
):
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
print(kwargs)
print(client)
print(await client.list_tool_specs())
return await client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
)
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_clients[server_id],
"direct": False,
}
except Exception as e:
log.debug(e)
continue
tools_dict = await get_tools(
request,
tool_ids,
@@ -1006,9 +1300,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__files__": metadata.get("files", []),
},
)
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if tool_servers:
for tool_server in tool_servers:
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
@@ -1018,6 +1314,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"server": tool_server,
}
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
@@ -1050,9 +1349,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
citation_idx_map = {}
for source in sources:
is_tool_result = source.get("tool_result", False)
if "document" in source and not is_tool_result:
if "document" in source:
for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
@@ -1079,26 +1376,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise Exception("No user message found")
if context_string != "":
# Workaround for Ollama 2.0+ system prompt issue
# TODO: replace with add_or_update_system_message
if model.get("owned_by") == "ollama":
form_data["messages"] = prepend_to_first_user_message_content(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
)
else:
form_data["messages"] = add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
)
form_data["messages"] = add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
append=False,
)
# If there are citations, add them to the data_items
sources = [
@@ -1124,6 +1410,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
)
print("Final form_data:", form_data)
print("Final metadata:", metadata)
print("Final events:", events)
return form_data, metadata, events
@@ -1498,7 +1788,7 @@ async def process_chat_response(
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token(
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
@@ -1581,7 +1871,8 @@ async def process_chat_response(
break
if tool_result is not None:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
tool_result_embeds = result.get("embeds", "")
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
@@ -2328,8 +2619,12 @@ async def process_chat_response(
results = []
for tool_call in response_tool_calls:
print("tool_call", tool_call)
tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get("name", "")
tool_function_name = tool_call.get("function", {}).get(
"name", ""
)
tool_args = tool_call.get("function", {}).get("arguments", "{}")
tool_function_params = {}
@@ -2359,11 +2654,17 @@ async def process_chat_response(
)
tool_result = None
tool = None
tool_type = None
direct_tool = False
if tool_name in tools:
tool = tools[tool_name]
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get("spec", {})
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
try:
allowed_params = (
spec.get("parameters", {})
@@ -2377,13 +2678,13 @@ async def process_chat_response(
if k in allowed_params
}
if tool.get("direct", False):
if direct_tool:
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_name,
"name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
@@ -2402,20 +2703,17 @@ async def process_chat_response(
except Exception as e:
tool_result = str(e)
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item)
tool_result.remove(item)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(
tool_result, indent=2, ensure_ascii=False
tool_result, tool_result_files, tool_result_embeds = (
process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool,
metadata,
user,
)
)
results.append(
{
@@ -2426,11 +2724,15 @@ async def process_chat_response(
if tool_result_files
else {}
),
**(
{"embeds": tool_result_embeds}
if tool_result_embeds
else {}
),
}
)
content_blocks[-1]["results"] = results
content_blocks.append(
{
"type": "text",
@@ -2571,23 +2873,18 @@ async def process_chat_response(
if isinstance(stdout, str):
stdoutLines = stdout.split("\n")
for idx, line in enumerate(stdoutLines):
if "data:image/png;base64" in line:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = (
load_b64_image_data(line)
image_url = get_image_url_from_base64(
request,
line,
metadata,
user,
)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
if image_url:
stdoutLines[idx] = (
f"![Output Image]({image_url})"
)
stdoutLines[idx] = (
f"![Output Image]({image_url})"
)
output["stdout"] = "\n".join(stdoutLines)
@@ -2597,19 +2894,12 @@ async def process_chat_response(
resultLines = result.split("\n")
for idx, line in enumerate(resultLines):
if "data:image/png;base64" in line:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = (
load_b64_image_data(line)
image_url = get_image_url_from_base64(
request,
line,
metadata,
user,
)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
resultLines[idx] = (
f"![Output Image]({image_url})"
)
+27 -26
View File
@@ -120,19 +120,20 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
return get_system_message(messages), remove_system_message(messages)
def prepend_to_first_user_message_content(
content: str, messages: list[dict]
) -> list[dict]:
for message in messages:
if message["role"] == "user":
if isinstance(message["content"], list):
for item in message["content"]:
if item["type"] == "text":
item["text"] = f"{content}\n{item['text']}"
else:
message["content"] = f"{content}\n{message['content']}"
break
return messages
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
if isinstance(message["content"], list):
for item in message["content"]:
if item["type"] == "text":
if append:
item["text"] = f"{item['text']}\n{content}"
else:
item["text"] = f"{content}\n{item['text']}"
else:
if append:
message["content"] = f"{message['content']}\n{content}"
else:
message["content"] = f"{content}\n{message['content']}"
return message
def add_or_update_system_message(
@@ -148,10 +149,7 @@ def add_or_update_system_message(
"""
if messages and messages[0].get("role") == "system":
if append:
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
else:
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
messages[0] = update_message_content(messages[0], content, append)
else:
# Insert at the beginning
messages.insert(0, {"role": "system", "content": content})
@@ -159,7 +157,7 @@ def add_or_update_system_message(
return messages
def add_or_update_user_message(content: str, messages: list[dict]):
def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
"""
Adds a new user message at the end of the messages list
or updates the existing user message at the end.
@@ -170,7 +168,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
"""
if messages and messages[-1].get("role") == "user":
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
messages[-1] = update_message_content(messages[-1], content, append)
else:
# Insert at the end
messages.append({"role": "user", "content": content})
@@ -178,6 +176,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
return messages
def prepend_to_first_user_message_content(
content: str, messages: list[dict]
) -> list[dict]:
for message in messages:
if message["role"] == "user":
message = update_message_content(message, content, append=False)
break
return messages
def append_or_update_assistant_message(content: str, messages: list[dict]):
"""
Adds a new assistant message at the end of the messages list
@@ -383,17 +391,10 @@ def parse_ollama_modelfile(model_text):
"top_k": int,
"top_p": float,
"num_keep": int,
"typical_p": float,
"presence_penalty": float,
"frequency_penalty": float,
"penalize_newline": bool,
"numa": bool,
"num_batch": int,
"num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"f16_kv": bool,
"vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
+1
View File
@@ -263,6 +263,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
"has_user_valves": hasattr(module, "UserValves"),
}
]
+488 -9
View File
@@ -1,7 +1,9 @@
import base64
import hashlib
import logging
import mimetypes
import sys
import urllib
import uuid
import json
from datetime import datetime, timedelta
@@ -9,6 +11,9 @@ from datetime import datetime, timedelta
import re
import fnmatch
import time
import secrets
from cryptography.fernet import Fernet
import aiohttp
from authlib.integrations.starlette_client import OAuth
@@ -18,6 +23,7 @@ from fastapi import (
status,
)
from starlette.responses import RedirectResponse
from typing import Optional
from open_webui.models.auths import Auths
@@ -56,11 +62,27 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
from open_webui.utils.webhook import post_webhook
from mcp.shared.auth import (
OAuthClientMetadata,
OAuthMetadata,
)
class OAuthClientInformationFull(OAuthClientMetadata):
issuer: Optional[str] = None # URL of the OAuth server that issued this client
client_id: str
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@@ -89,6 +111,42 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
FERNET = None
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest()
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes)
else:
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()
try:
FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
raise
def encrypt_data(data) -> str:
"""Encrypt data for storage"""
try:
data_json = json.dumps(data)
encrypted = FERNET.encrypt(data_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting data: {e}")
raise
def decrypt_data(data: str):
"""Decrypt data from storage"""
try:
decrypted = FERNET.decrypt(data.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting data: {e}")
raise
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
"""
Check if a group name matches any blocked pattern.
@@ -133,6 +191,424 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool:
return False
def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
parsed = urllib.parse.urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
return parsed, base_url
def get_discovery_urls(server_url) -> list[str]:
parsed, base_url = get_parsed_and_base_url(server_url)
urls = [
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"),
urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"),
]
if parsed.path and parsed.path != "/":
urls.append(
urllib.parse.urljoin(
base_url,
f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}",
)
)
urls.append(
urllib.parse.urljoin(
base_url, f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
)
)
return urls
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
# This is not currently supported.
async def get_oauth_client_info_with_dynamic_client_registration(
request,
client_id: str,
oauth_server_url: str,
oauth_server_key: Optional[str] = None,
) -> OAuthClientInformationFull:
try:
oauth_server_metadata = None
oauth_server_metadata_url = None
redirect_base_url = (
str(request.app.state.config.WEBUI_URL or request.base_url)
).rstrip("/")
oauth_client_metadata = OAuthClientMetadata(
client_name="Open WebUI",
redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="client_secret_post",
)
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
discovery_urls = get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession() as session:
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status == 200:
try:
oauth_server_metadata = OAuthMetadata.model_validate(
await oauth_server_metadata_response.json()
)
oauth_server_metadata_url = url
if (
oauth_client_metadata.scope is None
and oauth_server_metadata.scopes_supported is not None
):
oauth_client_metadata.scope = " ".join(
oauth_server_metadata.scopes_supported
)
break
except Exception as e:
log.error(f"Error parsing OAuth metadata from {url}: {e}")
continue
registration_url = None
if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
registration_url = str(oauth_server_metadata.registration_endpoint)
else:
_, base_url = get_parsed_and_base_url(oauth_server_url)
registration_url = urllib.parse.urljoin(base_url, "/register")
registration_data = oauth_client_metadata.model_dump(
exclude_none=True,
mode="json",
by_alias=True,
)
# Perform dynamic client registration and return client info
async with aiohttp.ClientSession() as session:
async with session.post(
registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as oauth_client_registration_response:
try:
registration_response_json = (
await oauth_client_registration_response.json()
)
oauth_client_info = OAuthClientInformationFull.model_validate(
{
**registration_response_json,
**{"issuer": oauth_server_metadata_url},
}
)
log.info(
f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}"
)
return oauth_client_info
except Exception as e:
error_text = None
try:
error_text = await oauth_client_registration_response.text()
log.error(
f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}"
)
except Exception as e:
pass
log.error(f"Error parsing client registration response: {e}")
raise Exception(
f"Dynamic client registration failed: {error_text}"
if error_text
else "Error parsing client registration response"
)
raise Exception("Dynamic client registration failed")
except Exception as e:
log.error(f"Exception during dynamic client registration: {e}")
raise e
class OAuthClientManager:
def __init__(self, app):
self.oauth = OAuth()
self.app = app
self.clients = {}
def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
self.clients[client_id] = {
"client": self.oauth.register(
name=client_id,
client_id=oauth_client_info.client_id,
client_secret=oauth_client_info.client_secret,
client_kwargs=(
{"scope": oauth_client_info.scope}
if oauth_client_info.scope
else {}
),
server_metadata_url=(
oauth_client_info.issuer if oauth_client_info.issuer else None
),
),
"client_info": oauth_client_info,
}
return self.clients[client_id]
def remove_client(self, client_id):
if client_id in self.clients:
del self.clients[client_id]
log.info(f"Removed OAuth client {client_id}")
return True
def get_client(self, client_id):
client = self.clients.get(client_id)
return client["client"] if client else None
def get_client_info(self, client_id):
client = self.clients.get(client_id)
return client["client_info"] if client else None
def get_server_metadata_url(self, client_id):
if client_id in self.clients:
client = self.clients[client_id]
return (
client.server_metadata_url
if hasattr(client, "server_metadata_url")
else None
)
return None
async def get_oauth_token(
self, user_id: str, client_id: str, force_refresh: bool = False
):
"""
Get a valid OAuth token for the user, automatically refreshing if needed.
Args:
user_id: The user ID
client_id: The OAuth client ID (provider)
force_refresh: Force token refresh even if current token appears valid
Returns:
dict: OAuth token data with access_token, or None if no valid token available
"""
try:
# Get the OAuth session
session = OAuthSessions.get_session_by_provider_and_user_id(
client_id, user_id
)
if not session:
log.warning(
f"No OAuth session found for user {user_id}, client_id {client_id}"
)
return None
if force_refresh or datetime.now() + timedelta(
minutes=5
) >= datetime.fromtimestamp(session.expires_at):
log.debug(
f"Token refresh needed for user {user_id}, client_id {session.provider}"
)
refreshed_token = await self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}"
)
OAuthSessions.delete_session_by_id(session.id)
return None
return session.token
except Exception as e:
log.error(f"Error getting OAuth token for user {user_id}: {e}")
return None
async def _refresh_token(self, session) -> dict:
"""
Refresh an OAuth token if needed, with concurrency protection.
Args:
session: The OAuth session object
Returns:
dict: Refreshed token data, or None if refresh failed
"""
try:
# Perform the actual refresh
refreshed_token = await self._perform_token_refresh(session)
if refreshed_token:
# Update the session with new token data
session = OAuthSessions.update_session_by_id(
session.id, refreshed_token
)
log.info(f"Successfully refreshed token for session {session.id}")
return session.token
else:
log.error(f"Failed to refresh token for session {session.id}")
return None
except Exception as e:
log.error(f"Error refreshing token for session {session.id}: {e}")
return None
async def _perform_token_refresh(self, session) -> dict:
"""
Perform the actual OAuth token refresh.
Args:
session: The OAuth session object
Returns:
dict: New token data, or None if refresh failed
"""
client_id = session.provider
token_data = session.token
if not token_data.get("refresh_token"):
log.warning(f"No refresh token available for session {session.id}")
return None
try:
client = self.get_client(client_id)
if not client:
log.error(f"No OAuth client found for provider {client_id}")
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(
self.get_server_metadata_url(client_id)
) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
else:
log.error(
f"Failed to fetch OpenID configuration for client_id {client_id}"
)
if not token_endpoint:
log.error(f"No token endpoint found for client_id {client_id}")
return None
# Prepare refresh request
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": token_data["refresh_token"],
"client_id": client.client_id,
}
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
token_endpoint,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status == 200:
new_token_data = await r.json()
# Merge with existing token data (preserve refresh_token if not provided)
if "refresh_token" not in new_token_data:
new_token_data["refresh_token"] = token_data[
"refresh_token"
]
# Add timestamp for tracking
new_token_data["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if (
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = int(
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
log.debug(f"Token refresh successful for client_id {client_id}")
return new_token_data
else:
error_text = await r.text()
log.error(
f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}"
)
return None
except Exception as e:
log.error(f"Exception during token refresh for client_id {client_id}: {e}")
return None
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
client = self.get_client(client_id)
if client is None:
raise HTTPException(404)
client_info = self.get_client_info(client_id)
if client_info is None:
raise HTTPException(404)
redirect_uri = (
client_info.redirect_uris[0] if client_info.redirect_uris else None
)
return await client.authorize_redirect(request, str(redirect_uri))
async def handle_callback(self, request, client_id: str, user_id: str, response):
client = self.get_client(client_id)
if client is None:
raise HTTPException(404)
error_message = None
try:
token = await client.authorize_access_token(request)
if token:
try:
# Add timestamp for tracking
token["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = (
datetime.now().timestamp() + token["expires_in"]
)
# Clean up any existing sessions for this user/client_id first
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
for session in sessions:
if session.provider == client_id:
OAuthSessions.delete_session_by_id(session.id)
session = OAuthSessions.create_session(
user_id=user_id,
provider=client_id,
token=token,
)
log.info(
f"Stored OAuth session server-side for user {user_id}, client_id {client_id}"
)
except Exception as e:
error_message = "Failed to store OAuth session server-side"
log.error(f"Failed to store OAuth session server-side: {e}")
else:
error_message = "Failed to obtain OAuth token"
log.warning(error_message)
except Exception as e:
error_message = "OAuth callback error"
log.warning(f"OAuth callback error: {e}")
redirect_url = (
str(request.app.state.config.WEBUI_URL or request.base_url)
).rstrip("/")
if error_message:
log.debug(error_message)
redirect_url = f"{redirect_url}/?error={error_message}"
return RedirectResponse(url=redirect_url, headers=response.headers)
response = RedirectResponse(url=redirect_url, headers=response.headers)
return response
class OAuthManager:
def __init__(self, app):
self.oauth = OAuth()
@@ -157,7 +633,7 @@ class OAuthManager:
)
return None
def get_oauth_token(
async def get_oauth_token(
self, user_id: str, session_id: str, force_refresh: bool = False
):
"""
@@ -186,13 +662,15 @@ class OAuthManager:
log.debug(
f"Token refresh needed for user {user_id}, provider {session.provider}"
)
refreshed_token = self._refresh_token(session)
refreshed_token = await self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, provider {session.provider}"
f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}"
)
OAuthSessions.delete_session_by_id(session.id)
return None
return session.token
@@ -252,9 +730,10 @@ class OAuthManager:
log.error(f"No OAuth client found for provider {provider}")
return None
server_metadata_url = self.get_server_metadata_url(provider)
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(client.gserver_metadata_url) as r:
async with session_http.get(server_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
@@ -301,7 +780,7 @@ class OAuthManager:
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = (
new_token_data["expires_at"] = int(
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
@@ -574,7 +1053,7 @@ class OAuthManager:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for(
"oauth_callback", provider=provider
"oauth_login_callback", provider=provider
)
client = self.get_client(provider)
if client is None:
@@ -791,9 +1270,9 @@ class OAuthManager:
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
)
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
if redirect_base_url.endswith("/"):
redirect_base_url = redirect_base_url[:-1]
redirect_base_url = (
str(request.app.state.config.WEBUI_URL or request.base_url)
).rstrip("/")
redirect_url = f"{redirect_base_url}/auth"
if error_message:
-6
View File
@@ -153,17 +153,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"repeat_last_n": int,
"top_k": int,
"min_p": float,
"typical_p": float,
"repeat_penalty": float,
"presence_penalty": float,
"frequency_penalty": float,
"penalize_newline": bool,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"numa": bool,
"num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
+42
View File
@@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
os.unlink(temp_file.name)
def get_tool_module_from_cache(request, tool_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Tool not found: {tool_id}")
content = tool.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the tool content in the database
Tools.update_tool_by_id(tool_id, {"content": content})
if (
hasattr(request.app.state, "TOOL_CONTENTS")
and tool_id in request.app.state.TOOL_CONTENTS
) and (
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
):
if request.app.state.TOOL_CONTENTS[tool_id] == content:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
else:
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id)
if not hasattr(request.app.state, "TOOLS"):
request.app.state.TOOLS = {}
if not hasattr(request.app.state, "TOOL_CONTENTS"):
request.app.state.TOOL_CONTENTS = {}
request.app.state.TOOLS[tool_id] = tool_module
request.app.state.TOOL_CONTENTS[tool_id] = content
return tool_module, frontmatter
def get_function_module_from_cache(request, function_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
+155 -104
View File
@@ -96,92 +96,117 @@ async def get_tools(
for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id)
if tool is None:
if tool_id.startswith("server:"):
server_id = tool_id.split(":")[1]
splits = tool_id.split(":")
tool_server_data = None
for server in await get_tool_servers(request):
if server["id"] == server_id:
tool_server_data = server
break
if len(splits) == 2:
type = "openapi"
server_id = splits[1]
elif len(splits) == 3:
type = splits[1]
server_id = splits[2]
if tool_server_data is None:
log.warning(f"Tool server data not found for {server_id}")
server_id_splits = server_id.split("|")
if len(server_id_splits) == 2:
server_id = server_id_splits[0]
function_names = server_id_splits[1].split(",")
if type == "openapi":
tool_server_data = None
for server in await get_tool_servers(request):
if server["id"] == server_id:
tool_server_data = server
break
if tool_server_data is None:
log.warning(f"Tool server data not found for {server_id}")
continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[
tool_server_idx
]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(
function_name, tool_server_data, headers
):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"type": "external",
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(function_name, tool_server_data, headers):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
continue
else:
@@ -563,25 +588,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
error = str(err)
raise Exception(error)
data = {
"openapi": res,
"info": res.get("info", {}),
"specs": convert_openapi_to_tool_payload(res),
}
log.info(f"Fetched data: {data}")
return data
log.debug(f"Fetched data: {res}")
return res
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index
tasks = []
server_entries = []
for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
full_url = get_tool_server_url(server.get("url"), openapi_path)
if (
server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi"
):
info = server.get("info", {})
auth_type = server.get("auth_type", "bearer")
@@ -597,12 +617,34 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
if not id:
id = str(idx)
server_entries.append((id, idx, server, full_url, info, token))
server_url = server.get("url")
spec_type = server.get("spec_type", "url")
# Create async tasks to fetch data
tasks = [
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries
]
# Create async tasks to fetch data
task = None
if spec_type == "url":
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
spec_url = get_tool_server_url(server_url, openapi_path)
# Fetch from URL
task = get_tool_server_data(token, spec_url)
elif spec_type == "json" and server.get("spec", ""):
# Use provided JSON spec
spec_json = None
try:
spec_json = json.loads(server.get("spec", ""))
except Exception as e:
log.error(f"Error parsing JSON spec for tool server {id}: {e}")
if spec_json:
task = asyncio.sleep(
0,
result=spec_json,
)
if task:
tasks.append(task)
server_entries.append((id, idx, server, server_url, info, token))
# Execute tasks concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True)
@@ -614,8 +656,13 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
log.error(f"Failed to connect to {url} OpenAPI tool server")
continue
openapi_data = response.get("openapi", {})
response = {
"openapi": response,
"info": response.get("info", {}),
"specs": convert_openapi_to_tool_payload(response),
}
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
openapi_data["info"] = openapi_data.get("info", {})
@@ -646,7 +693,7 @@ async def execute_tool_server(
name: str,
params: Dict[str, Any],
server_data: Dict[str, Any],
) -> Any:
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
error = None
try:
openapi = server_data.get("openapi", {})
@@ -718,6 +765,7 @@ async def execute_tool_server(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
@@ -728,13 +776,15 @@ async def execute_tool_server(
except Exception:
response_data = await response.text()
return response_data
response_headers = response.headers
return (response_data, response_headers)
else:
async with request_method(
final_url,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
@@ -745,12 +795,13 @@ async def execute_tool_server(
except Exception:
response_data = await response.text()
return response_data
response_headers = response.headers
return (response_data, response_headers)
except Exception as err:
error = str(err)
log.exception(f"API Request Error: {error}")
return {"error": error}
return ({"error": error}, None)
def get_tool_server_url(url: Optional[str], path: str) -> str:
+28 -23
View File
@@ -2,58 +2,66 @@ fastapi==0.115.7
uvicorn[standard]==0.35.0
pydantic==2.11.7
python-multipart==0.0.20
itsdangerous==2.2.0
python-socketio==5.13.0
python-jose==3.4.0
passlib[bcrypt]==1.7.4
cryptography
bcrypt==4.3.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1
authlib==1.6.3
requests==2.32.4
requests==2.32.5
aiohttp==3.12.15
async-timeout
aiocache
aiofiles
starlette-compress==1.6.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.38
alembic==1.14.0
peewee==3.18.1
peewee-migrate==1.12.2
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
bcrypt==4.3.0
pymongo
redis
boto3==1.40.5
argon2-cffi==25.1.0
APScheduler==3.10.4
pycrdt==0.12.25
redis
pymongo
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
boto3==1.40.5
APScheduler==3.10.4
RestrictedPython==8.0
loguru==0.7.3
asgiref==3.8.1
# AI libraries
tiktoken
mcp==1.14.1
openai
anthropic
google-genai==1.32.0
google-genai==1.38.0
google-generativeai==0.8.5
tiktoken
langchain==0.3.26
langchain-community==0.3.27
langchain==0.3.27
langchain-community==0.3.29
fake-useragent==2.2.0
chromadb==1.0.20
opensearch-py==2.8.0
pymilvus==2.5.0
qdrant-client==1.14.3
opensearch-py==2.8.0
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==9.1.0
pinecone==6.0.2
@@ -61,12 +69,12 @@ oracledb==3.2.0
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
transformers
sentence-transformers==4.1.0
sentence-transformers==5.1.1
accelerate
colbert-ai==0.2.21
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1
colbert-ai==0.2.21
ftfy==6.2.3
pypdf==6.0.0
@@ -94,11 +102,8 @@ rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
onnxruntime==1.20.1
faster-whisper==1.1.1
PyJWT[crypto]==2.10.1
authlib==1.6.3
black==25.1.0
youtube-transcript-api==1.2.2
@@ -120,7 +125,7 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.70.0
google-cloud-storage==2.19.0
azure-identity==1.23.0
azure-identity==1.25.0
azure-storage-blob==12.24.1
+45 -20
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.6.30",
"version": "0.6.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.30",
"version": "0.6.32",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@@ -23,7 +23,7 @@
"@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0",
@@ -39,6 +39,7 @@
"@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5",
"bits-ui": "^0.21.15",
"chart.js": "^4.5.0",
@@ -3383,9 +3384,9 @@
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3393,8 +3394,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/pm": "^3.0.7",
"@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3",
"yjs": "^13"
}
@@ -3413,9 +3414,9 @@
}
},
"node_modules/@tiptap/extension-drag-handle": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13"
@@ -3425,10 +3426,10 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/extension-collaboration": "^3.0.7",
"@tiptap/extension-node-range": "^3.0.7",
"@tiptap/pm": "^3.0.7",
"@tiptap/core": "^3.4.5",
"@tiptap/extension-collaboration": "^3.4.5",
"@tiptap/extension-node-range": "^3.4.5",
"@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3"
}
},
@@ -3642,9 +3643,9 @@
}
},
"node_modules/@tiptap/extension-node-range": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3652,8 +3653,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7",
"@tiptap/pm": "^3.0.7"
"@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.4.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {
@@ -4569,6 +4570,21 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/@webreflection/fetch": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
@@ -4672,6 +4688,15 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alpinejs": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/amator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.6.30",
"version": "0.6.32",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -67,7 +67,7 @@
"@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0",
@@ -83,6 +83,7 @@
"@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5",
"bits-ui": "^0.21.15",
"chart.js": "^4.5.0",
+11 -7
View File
@@ -10,23 +10,25 @@ dependencies = [
"uvicorn[standard]==0.35.0",
"pydantic==2.11.7",
"python-multipart==0.0.20",
"itsdangerous==2.2.0",
"python-socketio==5.13.0",
"python-jose==3.4.0",
"passlib[bcrypt]==1.7.4",
"cryptography",
"bcrypt==4.3.0",
"argon2-cffi==23.1.0",
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.10.1",
"authlib==1.6.3",
"requests==2.32.4",
"requests==2.32.5",
"aiohttp==3.12.15",
"async-timeout",
"aiocache",
"aiofiles",
"starlette-compress==1.6.0",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1",
"sqlalchemy==2.0.38",
"alembic==1.14.0",
@@ -46,20 +48,22 @@ dependencies = [
"asgiref==3.8.1",
"tiktoken",
"mcp==1.14.1",
"openai",
"anthropic",
"google-genai==1.32.0",
"google-genai==1.38.0",
"google-generativeai==0.8.5",
"langchain==0.3.26",
"langchain-community==0.3.27",
"langchain==0.3.27",
"langchain-community==0.3.29",
"fake-useragent==2.2.0",
"chromadb==1.0.20",
"opensearch-py==2.8.0",
"transformers",
"sentence-transformers==4.1.0",
"sentence-transformers==5.1.1",
"accelerate",
"pyarrow==20.0.0",
"einops==0.8.1",
@@ -108,7 +112,7 @@ dependencies = [
"googleapis-common-protos==1.70.0",
"google-cloud-storage==2.19.0",
"azure-identity==1.20.0",
"azure-identity==1.25.0",
"azure-storage-blob==12.24.1",
"ldap3==2.9.1",
+112 -3
View File
@@ -116,7 +116,7 @@ li p {
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: rgba(215, 215, 215, 0.8);
background-color: rgba(215, 215, 215, 0.6);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
@@ -124,12 +124,12 @@ li p {
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
}
::-webkit-scrollbar {
height: 0.6rem;
height: 0.4rem;
width: 0.4rem;
}
@@ -661,3 +661,112 @@ body {
background: #171717;
color: #eee;
}
/* Position the handle relative to each LI */
.pm-li--with-handle {
position: relative;
margin-left: 12px; /* make space for the handle */
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
margin-left: 0px;
}
/* The drag handle itself */
.pm-list-drag-handle {
position: absolute;
left: -36px; /* pull into the left gutter */
top: 1px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
border-radius: 4px;
cursor: grab;
user-select: none;
opacity: 0.35;
transition:
opacity 120ms ease,
background 120ms ease;
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
left: -16px; /* pull into the left gutter more to avoid the checkbox */
}
.pm-list-drag-handle:active {
cursor: grabbing;
}
.pm-li--with-handle:hover > .pm-list-drag-handle {
opacity: 1;
}
.pm-list-drag-handle:hover {
background: rgba(0, 0, 0, 0.06);
}
:root {
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
}
/* BEFORE/AFTER lines */
.pm-li-drop-before::before,
.pm-li-drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 3px;
background: var(--pm-accent);
pointer-events: none;
}
.pm-li-drop-before::before {
top: -2px;
}
.pm-li-drop-after::after {
bottom: -2px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
background: var(--pm-fill-target);
border-radius: 6px;
}
.pm-li-drop-outdent::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 3px;
background: color-mix(in oklab, Highlight 35%, transparent);
}
.pm-li--with-handle:has(.pm-li-drop-before),
.pm-li--with-handle:has(.pm-li-drop-after),
.pm-li--with-handle:has(.pm-li-drop-into),
.pm-li--with-handle:has(.pm-li-drop-outdent) {
background: var(--pm-fill-ancestor);
border-radius: 6px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
z-index: 0;
}
+1
View File
@@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
};
type MessageForm = {
reply_to_id?: string;
parent_id?: string;
content: string;
data?: object;
+80 -1
View File
@@ -33,6 +33,38 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
return res;
};
export const unarchiveAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const importChat = async (
token: string,
chat: object,
@@ -77,7 +109,11 @@ export const importChat = async (
return res;
};
export const getChatList = async (token: string = '', page: number | null = null) => {
export const getChatList = async (
token: string = '',
page: number | null = null,
include_folders: boolean = false
) => {
let error = null;
const searchParams = new URLSearchParams();
@@ -85,6 +121,10 @@ export const getChatList = async (token: string = '', page: number | null = null
searchParams.append('page', `${page}`);
}
if (include_folders) {
searchParams.append('include_folders', 'true');
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
method: 'GET',
headers: {
@@ -319,6 +359,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
return res;
};
export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
let error = null;
const searchParams = new URLSearchParams();
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(
`${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllArchivedChats = async (token: string) => {
let error = null;
+47 -1
View File
@@ -1,4 +1,4 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types';
export const importConfig = async (token: string, config) => {
@@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
return res;
};
type RegisterOAuthClientForm = {
url: string;
client_id: string;
client_name?: string;
};
export const registerOAuthClient = async (
token: string,
formData: RegisterOAuthClientForm,
type: null | string = null
) => {
let error = null;
const searchParams = type ? `?type=${type}` : '';
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...formData
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
const oauthClientId = type ? `${type}:${clientId}` : clientId;
return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
};
export const getCodeExecutionConfig = async (token: string) => {
let error = null;
+1 -1
View File
@@ -23,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
return res.json();
})
.catch((err) => {
error = err.detail;
error = err.detail || err.message;
console.error(err);
return null;
});
+45 -27
View File
@@ -337,14 +337,8 @@ export const getToolServerData = async (token: string, url: string) => {
throw error;
}
const data = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
console.log(res);
return res;
};
export const getToolServersData = async (servers: object[]) => {
@@ -356,6 +350,7 @@ export const getToolServersData = async (servers: object[]) => {
let error = null;
let toolServerToken = null;
const auth_type = server?.auth_type ?? 'bearer';
if (auth_type === 'bearer') {
toolServerToken = server?.key;
@@ -365,18 +360,34 @@ export const getToolServersData = async (servers: object[]) => {
toolServerToken = localStorage.token;
}
const data = await getToolServerData(
toolServerToken,
(server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => {
error = err;
return null;
});
let res = null;
const specType = server?.spec_type ?? 'url';
if (specType === 'url') {
res = await getToolServerData(
toolServerToken,
(server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => {
error = err;
return null;
});
} else if ((specType === 'json' && server?.spec) ?? null) {
try {
res = JSON.parse(server?.spec);
} catch (e) {
error = 'Failed to parse JSON spec';
}
}
if (res) {
const { openapi, info, specs } = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
if (data) {
const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,
@@ -493,18 +504,25 @@ export const executeToolServer = async (
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
}
let responseData;
try {
responseData = await res.json();
} catch (err) {
responseData = await res.text();
}
// make a clone of res and extract headers
const responseHeaders = {};
res.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return responseData;
const text = await res.text();
let responseData;
try {
responseData = JSON.parse(text);
} catch {
responseData = text;
}
return [responseData, responseHeaders];
} catch (err: any) {
error = err.message;
console.error('API Request Error:', error);
return { error };
return [{ error }, null];
}
};
+28
View File
@@ -31,6 +31,34 @@ export const getModels = async (token: string = '') => {
return res;
};
export const importModels = async (token: string, models: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ models: models })
})
.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 getBaseModels = async (token: string = '') => {
let error = null;
-440
View File
@@ -1,440 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let direct = false;
export let connection = null;
let url = '';
let path = 'openapi.json';
let auth_type = 'bearer';
let key = '';
let accessControl = {};
let id = '';
let name = '';
let description = '';
let enable = true;
let loading = false;
const verifyHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
if (path === '') {
toast.error($i18n.t('Please enter a valid path'));
return;
}
if (direct) {
const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token,
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
} else {
const res = await verifyToolServerConnection(localStorage.token, {
url,
path,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
},
info: {
id,
name,
description
}
}).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
}
};
const submitHandler = async () => {
loading = true;
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
path,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
},
info: {
id: id,
name: name,
description: description
}
};
await onSubmit(connection);
loading = false;
show = false;
url = '';
path = 'openapi.json';
key = '';
auth_type = 'bearer';
id = '';
name = '';
description = '';
enable = true;
accessControl = null;
};
const init = () => {
if (connection) {
url = connection.url;
path = connection?.path ?? 'openapi.json';
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
id = connection.info?.id ?? '';
name = connection.info?.name ?? '';
description = connection.info?.description ?? '';
enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null;
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="api-base-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
required
/>
<Tooltip
content={$i18n.t('Verify Connection')}
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
aria-label={$i18n.t('Verify Connection')}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
</div>
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<label
for="enter-id"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
</label>
<div class="flex-1">
<input
id="enter-id"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="enter-name"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}
</label>
<div class="flex-1">
<input
id="enter-name"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<label
for="description"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,790 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let direct = false;
export let connection = null;
let inputElement = null;
let type = 'openapi'; // 'openapi', 'mcp'
let url = '';
let spec_type = 'url'; // 'url', 'json'
let spec = ''; // used when spec_type is 'json'
let path = 'openapi.json';
let auth_type = 'bearer';
let key = '';
let accessControl = {};
let id = '';
let name = '';
let description = '';
let oauthClientInfo = null;
let enable = true;
let loading = false;
const registerOAuthClientHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
if (id === '') {
toast.error($i18n.t('Please enter a valid ID'));
return;
}
const res = await registerOAuthClient(
localStorage.token,
{
url: url,
client_id: id
},
'mcp'
).catch((err) => {
toast.error($i18n.t('Registration failed'));
return null;
});
if (res) {
toast.warning(
$i18n.t(
'Please save the connection to persist the OAuth client information and do not change the ID'
)
);
toast.success($i18n.t('Registration successful'));
console.debug('Registration successful', res);
oauthClientInfo = res?.oauth_client_info ?? null;
}
};
const verifyHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
if (path === '') {
toast.error($i18n.t('Please enter a valid path'));
return;
}
if (direct) {
const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token,
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
} else {
const res = await verifyToolServerConnection(localStorage.token, {
url,
path,
type,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
},
info: {
id,
name,
description
}
}).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
if (res) {
toast.success($i18n.t('Connection successful'));
console.debug('Connection successful', res);
}
}
};
const importHandler = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target.result;
console.log('importHandler', json);
try {
let data = JSON.parse(json);
// validate data
if (Array.isArray(data)) {
if (data.length === 0) {
toast.error($i18n.t('Please select a valid JSON file'));
return;
}
data = data[0];
}
if (data.type) type = data.type;
if (data.url) url = data.url;
if (data.spec_type) spec_type = data.spec_type;
if (data.spec) spec = data.spec;
if (data.path) path = data.path;
if (data.auth_type) auth_type = data.auth_type;
if (data.key) key = data.key;
if (data.info) {
id = data.info.id ?? '';
name = data.info.name ?? '';
description = data.info.description ?? '';
}
if (data.config) {
enable = data.config.enable ?? true;
accessControl = data.config.access_control ?? {};
}
toast.success($i18n.t('Import successful'));
} catch (error) {
toast.error($i18n.t('Please select a valid JSON file'));
}
};
reader.readAsText(file);
};
const exportHandler = async () => {
// export current connection as json file
const json = JSON.stringify([
{
type,
url,
spec_type,
spec,
path,
auth_type,
key,
info: {
id: id,
name: name,
description: description
}
}
]);
const blob = new Blob([json], {
type: 'application/json'
});
saveAs(blob, `tool-server-${id || name || 'export'}.json`);
};
const submitHandler = async () => {
loading = true;
// remove trailing slash from url
url = url.replace(/\/$/, '');
if (id.includes(':') || id.includes('|')) {
toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
loading = false;
return;
}
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
toast.error($i18n.t('Please register the OAuth client'));
loading = false;
return;
}
// validate spec
if (spec_type === 'json') {
try {
const specJSON = JSON.parse(spec);
spec = JSON.stringify(specJSON, null, 2);
} catch (e) {
toast.error($i18n.t('Please enter a valid JSON spec'));
loading = false;
return;
}
}
const connection = {
type,
url,
spec_type,
spec,
path,
auth_type,
key,
config: {
enable: enable,
access_control: accessControl
},
info: {
id: id,
name: name,
description: description,
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
}
};
await onSubmit(connection);
loading = false;
show = false;
// reset form
type = 'openapi';
url = '';
spec_type = 'url';
spec = '';
path = 'openapi.json';
key = '';
auth_type = 'bearer';
id = '';
name = '';
description = '';
oauthClientInfo = null;
enable = true;
accessControl = null;
};
const init = () => {
if (connection) {
type = connection?.type ?? 'openapi';
url = connection.url;
spec_type = connection?.spec_type ?? 'url';
spec = connection?.spec ?? '';
path = connection?.path ?? 'openapi.json';
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
id = connection.info?.id ?? '';
name = connection.info?.name ?? '';
description = connection.info?.description ?? '';
oauthClientInfo = connection.info?.oauth_client_info ?? null;
enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null;
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</h1>
<div class="flex items-center gap-3">
<div class="flex gap-1.5 text-xs justify-end">
<button
class=" hover:underline"
type="button"
on:click={() => {
inputElement?.click();
}}
>
{$i18n.t('Import')}
</button>
<button class=" hover:underline" type="button" on:click={exportHandler}>
{$i18n.t('Export')}
</button>
</div>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<input
bind:this={inputElement}
type="file"
hidden
accept=".json"
on:change={(e) => {
importHandler(e);
}}
/>
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
{#if !direct}
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="api-base-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
required
/>
<Tooltip
content={$i18n.t('Verify Connection')}
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
aria-label={$i18n.t('Verify Connection')}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
</div>
</div>
</div>
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Auth')}
</div>
</div>
{#if auth_type === 'oauth_2.1'}
<div class="flex items-center gap-2">
<div class="flex flex-col justify-end items-center shrink-0">
<Tooltip
content={oauthClientInfo
? $i18n.t('Register Again')
: $i18n.t('Register Client')}
>
<button
class=" text-xs underline dark:text-gray-500 dark:hover:text-gray-200 text-gray-700 hover:text-gray-900 transition"
type="button"
on:click={() => {
registerOAuthClientHandler();
}}
>
{$i18n.t('Register Client')}
</button>
</Tooltip>
</div>
{#if !oauthClientInfo}
<div
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
>
{$i18n.t('Not Registered')}
</div>
{:else}
<div
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
>
{$i18n.t('Registered')}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{#if type === 'mcp'}
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
{/if}
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if auth_type === 'oauth_2.1'}
<div
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<label
for="enter-id"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
{#if type !== 'mcp'}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{/if}
</label>
<div class="flex-1">
<input
id="enter-id"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="enter-name"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}
</label>
<div class="flex-1">
<input
id="enter-name"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<label
for="description"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>
{/if}
</div>
{#if type === 'mcp'}
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
>
<span class="font-medium">
{$i18n.t('Warning')}:
</span>
{$i18n.t(
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
)}
<a
class="font-medium underline"
href="https://docs.openwebui.com/features/mcp"
target="_blank">{$i18n.t('Read more →')}</a
>
</div>
{/if}
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<div></div>
<div class="flex gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</Modal>
+17 -14
View File
@@ -19,16 +19,19 @@
let changelog = null;
onMount(async () => {
const res = await getChangelog();
changelog = res;
});
const init = async () => {
changelog = await getChangelog();
};
$: if (show) {
init();
}
</script>
<Modal bind:show size="xl">
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<div class="text-xl font-semibold">
<div class="text-xl font-medium">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
@@ -48,7 +51,7 @@
</div>
<div class="flex items-center mt-1">
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
<div class="text-sm dark:text-gray-200">
v{WEBUI_VERSION}
</div>
@@ -56,7 +59,7 @@
</div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-[32rem] scrollbar-hidden">
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
<div class="mb-3">
{#if changelog}
{#each Object.keys(changelog) as version}
@@ -65,20 +68,20 @@
v{version} - {changelog[version].date}
</div>
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="w-full">
<div
class="font-semibold uppercase text-xs {section === 'added'
? 'text-white bg-blue-600'
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
: section === 'fixed'
? 'text-white bg-green-600'
? 'bg-green-500/20 text-green-700 dark:text-green-200'
: section === 'changed'
? 'text-white bg-yellow-600'
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
: section === 'removed'
? 'text-white bg-red-600'
: ''} w-fit px-3 rounded-full my-2.5"
? 'bg-red-500/20 text-red-700 dark:text-red-200'
: ''} w-fit rounded-xl px-2 my-2.5"
>
{section}
</div>
@@ -202,7 +202,7 @@
{:else}
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
@@ -389,7 +389,7 @@
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
+1 -1
View File
@@ -595,7 +595,7 @@
deleteHandler(selectedFunction);
}}
>
<div class=" text-sm text-gray-500">
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
</div>
</DeleteConfirmDialog>
@@ -4,6 +4,7 @@
const i18n = getContext('i18n');
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -366,22 +367,20 @@ class Pipe:
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
onChange={(e) => {
_content = e;
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
{/await}
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
onChange={(e) => {
_content = e;
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
</div>
<div class="pb-3 flex justify-between">
@@ -1143,7 +1143,7 @@
<div class=" mb-2.5 py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5'
'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
)}
placement="top-start"
className="inline-tooltip"
@@ -293,7 +293,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>
@@ -34,7 +34,7 @@
<div class="w-full flex flex-col">
<div class="flex items-center gap-1">
<div class="shrink-0 line-clamp-1">
<div class=" line-clamp-1">
{model.name}
</div>
</div>
+26 -30
View File
@@ -12,7 +12,8 @@
deleteAllModels,
getBaseModels,
toggleModelById,
updateModelById
updateModelById,
importModels
} from '$lib/apis/models';
import { copyToClipboard } from '$lib/utils';
import { page } from '$app/stores';
@@ -40,6 +41,7 @@
let shiftKey = false;
let modelsImportInProgress = false;
let importFiles;
let modelsImportInputElement: HTMLInputElement;
@@ -464,47 +466,41 @@
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const models = JSON.parse(String(event.target.result));
modelsImportInProgress = true;
const res = await importModels(localStorage.token, models);
modelsImportInProgress = false;
let reader = new FileReader();
reader.onload = async (event) => {
let savedModels = JSON.parse(event.target.result);
console.log(savedModels);
for (const model of savedModels) {
if (Object.keys(model).includes('base_model_id')) {
if (model.base_model_id === null) {
upsertModelHandler(model);
}
} else {
if (model?.info ?? false) {
if (model.info.base_model_id === null) {
upsertModelHandler(model.info);
}
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error($i18n.t('Invalid JSON file'));
console.error(e);
}
}
await _models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
init();
};
reader.readAsText(importFiles[0]);
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Presets')}
</div>
@@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
export let saveSettings: Function;
@@ -47,7 +47,7 @@
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form
class="flex flex-col h-full justify-between text-sm"
@@ -13,6 +13,8 @@
export let saveHandler: Function;
let webSearchEngines = [
'ollama_cloud',
'perplexity_search',
'searxng',
'yacy',
'google_pse',
@@ -130,7 +132,41 @@
</div>
{#if webConfig.WEB_SEARCH_ENGINE !== ''}
{#if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
{#if webConfig.WEB_SEARCH_ENGINE === 'ollama_cloud'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Ollama Cloud API Key')}
</div>
<div class="flex w-full">
<div class="flex-1">
<SensitiveInput
placeholder={$i18n.t('Enter Ollama Cloud API Key')}
bind:value={webConfig.OLLAMA_CLOUD_WEB_SEARCH_API_KEY}
/>
</div>
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'perplexity_search'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Perplexity API Key')}
</div>
<div class="flex w-full">
<div class="flex-1">
<SensitiveInput
placeholder={$i18n.t('Enter Perplexity API Key')}
bind:value={webConfig.PERPLEXITY_API_KEY}
/>
</div>
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
+22 -53
View File
@@ -19,10 +19,9 @@
import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
import EditGroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import {
getUserDefaultPermissions,
@@ -51,54 +50,20 @@
});
let search = '';
let defaultPermissions = {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
valves: true,
system_prompt: true,
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
stt: true,
tts: true,
call: true,
multiple_models: true,
temporary: true,
temporary_enforced: false
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true,
notes: true
}
};
let defaultPermissions = {};
let showCreateGroupModal = false;
let showAddGroupModal = false;
let showDefaultPermissionsModal = false;
const setGroups = async () => {
groups = await getGroups(localStorage.token);
const allGroups = await getGroups(localStorage.token);
const userGroup = allGroups.find((g) => g.name.toLowerCase() === 'user');
if (userGroup) {
defaultPermissions = userGroup.permissions;
}
groups = allGroups.filter((g) => g.name.toLowerCase() !== 'user');
};
const addGroupHandler = async (group) => {
@@ -146,14 +111,18 @@
}
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
loaded = true;
});
</script>
{#if loaded}
<AddGroupModal bind:show={showCreateGroupModal} onSubmit={addGroupHandler} />
<EditGroupModal
bind:show={showAddGroupModal}
edit={false}
permissions={defaultPermissions}
onSubmit={addGroupHandler}
/>
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Groups')}
@@ -180,7 +149,7 @@
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
showCreateGroupModal = !showCreateGroupModal;
showAddGroupModal = !showAddGroupModal;
}}
>
<Plus className="size-3.5" />
@@ -207,7 +176,7 @@
class=" px-4 py-1.5 text-sm rounded-full bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition font-medium flex items-center space-x-1"
aria-label={$i18n.t('Create Group')}
on:click={() => {
showCreateGroupModal = true;
showAddGroupModal = true;
}}
>
{$i18n.t('Create Group')}
@@ -226,7 +195,7 @@
{#each filteredGroups as group}
<div class="my-2">
<GroupItem {group} {users} {setGroups} />
<GroupItem {group} {users} {setGroups} {defaultPermissions} />
</div>
{/each}
</div>
@@ -234,7 +203,7 @@
<hr class="mb-2 border-gray-100 dark:border-gray-850" />
<GroupModal
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
@@ -1,116 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let show = false;
let name = '';
let description = '';
let userIds = [];
let loading = false;
const submitHandler = async () => {
loading = true;
const group = {
name,
description
};
await onSubmit(group);
loading = false;
show = false;
name = '';
description = '';
userIds = [];
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Add User Group')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1 flex flex-col w-full">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Create')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
@@ -21,6 +21,7 @@
export let users = [];
export let group = null;
export let defaultPermissions = {};
export let custom = true;
@@ -230,7 +231,7 @@
{#if selectedTab == 'general'}
<Display bind:name bind:description />
{:else if selectedTab == 'permissions'}
<Permissions bind:permissions />
<Permissions bind:permissions {defaultPermissions} />
{:else if selectedTab == 'users'}
<Users bind:userIds {users} />
{/if}
@@ -17,6 +17,7 @@
name: 'Admins',
user_ids: [1, 2, 3]
};
export let defaultPermissions = {};
export let setGroups = () => {};
@@ -59,6 +60,7 @@
edit
{users}
{group}
{defaultPermissions}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>
@@ -6,7 +6,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
// Default values for permissions
const defaultPermissions = {
const DEFAULT_PERMISSIONS = {
workspace: {
models: false,
knowledge: false,
@@ -17,7 +17,8 @@
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
public_tools: false,
public_notes: false
},
chat: {
controls: true,
@@ -50,10 +51,11 @@
};
export let permissions = {};
export let defaultPermissions = {};
// Reactive statement to ensure all fields are present in `permissions`
$: {
permissions = fillMissingProperties(permissions, defaultPermissions);
permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
}
function fillMissingProperties(obj: any, defaults: any) {
@@ -68,140 +70,70 @@
}
onMount(() => {
permissions = fillMissingProperties(permissions, defaultPermissions);
permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
});
</script>
<div>
<!-- <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Model Permissions')}</div>
<div class="mb-2">
<div class="flex justify-between items-center text-xs pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Filtering')}</div>
<Switch bind:state={permissions.model.filter} />
</div>
</div>
{#if permissions.model.filter}
<div class="mb-2">
<div class=" space-y-1.5">
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Model IDs')}</div>
</div>
{#if model_ids.length > 0}
<div class="flex flex-col">
{#each model_ids as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
type="button"
on:click={() => {
model_ids = model_ids.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No model IDs')}
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-2.5 mb-1 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models.filter((m) => m?.owned_by !== 'arena') as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId && !permissions.model.model_ids.includes(selectedModelId)) {
permissions.model.model_ids = [...permissions.model.model_ids, selectedModelId];
selectedModelId = '';
}
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
{/if}
<div class=" space-y-1 mb-3">
<div class="">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-hidden py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder={$i18n.t('Select a model')}
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each permissions.model.filter ? $models.filter( (model) => filterModelIds.includes(model.id) ) : $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> -->
<div class="space-y-2">
<!-- {$i18n.t('Default Model')}
{$i18n.t('Model Filtering')}
{$i18n.t('Model Permissions')}
{$i18n.t('No model IDs')} -->
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Access')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Access')}
</div>
<Switch bind:state={permissions.workspace.models} />
</div>
<Switch bind:state={permissions.workspace.models} />
{#if defaultPermissions?.workspace?.models && !permissions.workspace.models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Access')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Access')}
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div>
<Switch bind:state={permissions.workspace.knowledge} />
{#if defaultPermissions?.workspace?.knowledge && !permissions.workspace.knowledge}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Access')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Access')}
</div>
<Switch bind:state={permissions.workspace.prompts} />
</div>
<Switch bind:state={permissions.workspace.prompts} />
{#if defaultPermissions?.workspace?.prompts && !permissions.workspace.prompts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" ">
<div class="flex flex-col w-full">
<Tooltip
className=" flex w-full justify-between my-2 pr-2"
className="flex w-full justify-between my-1"
content={$i18n.t(
'Warning: Enabling this will allow users to upload arbitrary code on the server.'
)}
@@ -212,247 +144,499 @@
</div>
<Switch bind:state={permissions.workspace.tools} />
</Tooltip>
{#if defaultPermissions?.workspace?.tools && !permissions.workspace.tools}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
</div>
<Switch bind:state={permissions.sharing.public_models} />
{#if defaultPermissions?.sharing?.public_models && !permissions.sharing.public_models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
{#if defaultPermissions?.sharing?.public_knowledge && !permissions.sharing.public_knowledge}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
{#if defaultPermissions?.sharing?.public_prompts && !permissions.sharing.public_prompts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
</div>
<Switch bind:state={permissions.sharing.public_tools} />
{#if defaultPermissions?.sharing?.public_tools && !permissions.sharing.public_tools}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_notes} />
</div>
{#if defaultPermissions?.sharing?.public_notes && !permissions.sharing.public_notes}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
</div>
<Switch bind:state={permissions.chat.file_upload} />
</div>
<Switch bind:state={permissions.chat.file_upload} />
{#if defaultPermissions?.chat?.file_upload && !permissions.chat.file_upload}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
<Switch bind:state={permissions.chat.controls} />
{#if defaultPermissions?.chat?.controls && !permissions.chat.controls}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{#if permissions.chat.controls}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Valves')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Valves')}
</div>
<Switch bind:state={permissions.chat.valves} />
</div>
<Switch bind:state={permissions.chat.valves} />
{#if defaultPermissions?.chat?.valves && !permissions.chat.valves}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat System Prompt')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat System Prompt')}
</div>
<Switch bind:state={permissions.chat.system_prompt} />
</div>
<Switch bind:state={permissions.chat.system_prompt} />
{#if defaultPermissions?.chat?.system_prompt && !permissions.chat.system_prompt}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Params')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Params')}
</div>
<Switch bind:state={permissions.chat.params} />
</div>
<Switch bind:state={permissions.chat.params} />
{#if defaultPermissions?.chat?.params && !permissions.chat.params}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
</div>
<Switch bind:state={permissions.chat.edit} />
</div>
<Switch bind:state={permissions.chat.edit} />
{#if defaultPermissions?.chat?.edit && !permissions.chat.edit}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div>
<Switch bind:state={permissions.chat.delete} />
{#if defaultPermissions?.chat?.delete && !permissions.chat.delete}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Delete Messages')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Delete Messages')}
</div>
<Switch bind:state={permissions.chat.delete_message} />
</div>
<Switch bind:state={permissions.chat.delete_message} />
{#if defaultPermissions?.chat?.delete_message && !permissions.chat.delete_message}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Continue Response')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Continue Response')}
</div>
<Switch bind:state={permissions.chat.continue_response} />
</div>
<Switch bind:state={permissions.chat.continue_response} />
{#if defaultPermissions?.chat?.continue_response && !permissions.chat.continue_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Regenerate Response')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Regenerate Response')}
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
{#if defaultPermissions?.chat?.regenerate_response && !permissions.chat.regenerate_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Rate Response')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Rate Response')}
</div>
<Switch bind:state={permissions.chat.rate_response} />
</div>
<Switch bind:state={permissions.chat.rate_response} />
{#if defaultPermissions?.chat?.rate_response && !permissions.chat.rate_response}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Share')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Share')}
</div>
<Switch bind:state={permissions.chat.share} />
</div>
<Switch bind:state={permissions.chat.share} />
{#if defaultPermissions?.chat?.share && !permissions.chat.share}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Export')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Export')}
</div>
<Switch bind:state={permissions.chat.export} />
</div>
<Switch bind:state={permissions.chat.export} />
{#if defaultPermissions?.chat?.export && !permissions.chat.export}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Speech to Text')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Speech to Text')}
</div>
<Switch bind:state={permissions.chat.stt} />
</div>
<Switch bind:state={permissions.chat.stt} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
{#if defaultPermissions?.chat?.stt && !permissions.chat.stt}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Call')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
</div>
<Switch bind:state={permissions.chat.call} />
{#if defaultPermissions?.chat?.tts && !permissions.chat.tts}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Multiple Models in Chat')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Call')}
</div>
<Switch bind:state={permissions.chat.call} />
</div>
<Switch bind:state={permissions.chat.multiple_models} />
{#if defaultPermissions?.chat?.call && !permissions.chat.call}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Temporary Chat')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Multiple Models in Chat')}
</div>
<Switch bind:state={permissions.chat.multiple_models} />
</div>
{#if defaultPermissions?.chat?.multiple_models && !permissions.chat.multiple_models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<Switch bind:state={permissions.chat.temporary} />
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
{#if defaultPermissions?.chat?.temporary && !permissions.chat.temporary}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{#if permissions.chat.temporary}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
{#if defaultPermissions?.chat?.temporary_enforced && !permissions.chat.temporary_enforced}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Direct Tool Servers')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Direct Tool Servers')}
</div>
<Switch bind:state={permissions.features.direct_tool_servers} />
</div>
<Switch bind:state={permissions.features.direct_tool_servers} />
{#if defaultPermissions?.features?.direct_tool_servers && !permissions.features.direct_tool_servers}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
</div>
<Switch bind:state={permissions.features.web_search} />
</div>
<Switch bind:state={permissions.features.web_search} />
{#if defaultPermissions?.features?.web_search && !permissions.features.web_search}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation')}
</div>
<Switch bind:state={permissions.features.image_generation} />
</div>
<Switch bind:state={permissions.features.image_generation} />
{#if defaultPermissions?.features?.image_generation && !permissions.features.image_generation}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter')}
</div>
<Switch bind:state={permissions.features.code_interpreter} />
</div>
<Switch bind:state={permissions.features.code_interpreter} />
{#if defaultPermissions?.features?.code_interpreter && !permissions.features.code_interpreter}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')}
</div>
<Switch bind:state={permissions.features.notes} />
</div>
<Switch bind:state={permissions.features.notes} />
{#if defaultPermissions?.features?.notes && !permissions.features.notes}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -75,10 +75,10 @@
/>
</div>
<div class="flex w-full items-center justify-between">
<div class="flex w-full items-center justify-between overflow-hidden">
<Tooltip content={user.email} placement="top-start">
<div class="flex">
<div class=" font-medium self-center">{user.name}</div>
<div class=" font-medium self-center truncate">{user.name}</div>
</div>
</Tooltip>
+5 -31
View File
@@ -222,7 +222,7 @@
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
@@ -339,30 +339,6 @@
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if orderBy === 'oauth_sub'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right" />
</tr>
</thead>
@@ -383,10 +359,10 @@
/>
</button>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max">
<div class="flex flex-row w-max">
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<div class="flex items-center">
<img
class=" rounded-full w-6 h-6 object-cover mr-2.5"
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
@@ -395,7 +371,7 @@
alt="user"
/>
<div class=" font-medium self-center">{user.name}</div>
<div class="font-medium truncate">{user.name}</div>
</div>
</td>
<td class=" px-3 py-1"> {user.email} </td>
@@ -408,8 +384,6 @@
{dayjs(user.created_at * 1000).format('LL')}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
@@ -12,6 +12,7 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import UserProfileImage from '$lib/components/chat/Settings/Account/UserProfileImage.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -83,110 +84,118 @@
submitHandler();
}}
>
<div class=" flex items-center rounded-md px-5 py-2 w-full">
<div class=" self-center mr-5">
<img
src={selectedUser.profile_image_url}
class=" max-w-[55px] object-cover rounded-full"
alt="User profile"
/>
</div>
<div>
<div class=" self-center capitalize font-semibold">{selectedUser.name}</div>
<div class="text-xs text-gray-500">
{$i18n.t('Created at')}
{dayjs(selectedUser.created_at * 1000).format('LL')}
<div class=" px-5 pt-3 pb-5 w-full">
<div class="flex self-center w-full">
<div class=" self-start h-full mr-6">
<UserProfileImage bind:profileImageUrl={_user.profile_image_url} user={_user} />
</div>
</div>
</div>
<div class=" px-5 pt-3 pb-5">
<div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
<div class=" flex-1">
<div class="overflow-hidden w-ful mb-2">
<div class=" self-center capitalize font-medium truncate">
{selectedUser.name}
</div>
<div class="flex-1">
<select
class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
disabled={_user.id == sessionUser.id}
required
>
<option value="admin">{$i18n.t('Admin')}</option>
<option value="user">{$i18n.t('User')}</option>
<option value="pending">{$i18n.t('Pending')}</option>
</select>
<div class="text-xs text-gray-500">
{$i18n.t('Created at')}
{dayjs(selectedUser.created_at * 1000).format('LL')}
</div>
</div>
</div>
{#if userGroups}
<div class="flex flex-col w-full text-sm">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
<div class=" flex flex-col space-y-1.5">
{#if (userGroups ?? []).length > 0}
<div class="flex flex-col w-full text-sm">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
{#if userGroups.length}
<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
{#each userGroups as userGroup}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
<a
href={'/admin/users/groups?id=' + userGroup.id}
on:click|preventDefault={() =>
goto('/admin/users/groups?id=' + userGroup.id)}
<div class="flex flex-wrap gap-1 my-0.5 -mx-1">
{#each userGroups as userGroup}
<span
class="px-1.5 py-0.5 rounded-xl bg-gray-100 dark:bg-gray-850 text-xs"
>
{userGroup.name}
</a>
</span>
{/each}
<a
href={'/admin/users/groups?id=' + userGroup.id}
on:click|preventDefault={() =>
goto('/admin/users/groups?id=' + userGroup.id)}
>
{userGroup.name}
</a>
</span>
{/each}
</div>
</div>
{:else}
<span>-</span>
{/if}
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex-1">
<select
class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
disabled={_user.id == sessionUser.id}
required
>
<option value="admin">{$i18n.t('Admin')}</option>
<option value="user">{$i18n.t('User')}</option>
<option value="pending">{$i18n.t('Pending')}</option>
</select>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent outline-hidden"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Name')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent outline-hidden"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Name')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<SensitiveInput
class="w-full text-sm bg-transparent outline-hidden"
type="password"
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"
required={false}
/>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
/>
</div>
</div>
{#if _user?.oauth_sub}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
<div class="flex-1 text-sm break-all mb-1">
{_user.oauth_sub ?? ''}
</div>
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
<div class="flex-1">
<SensitiveInput
class="w-full text-sm bg-transparent outline-hidden"
type="password"
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"
required={false}
/>
</div>
</div>
</div>
</div>
</div>
@@ -43,6 +43,10 @@
let searchDebounceTimeout;
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
@@ -103,7 +107,9 @@
bind:query
bind:orderBy
bind:direction
title={$i18n.t("{{user}}'s Chats", { user: user.name })}
title={$i18n.t("{{user}}'s Chats", {
user: user.name.length > 32 ? `${user.name.slice(0, 32)}...` : user.name
})}
emptyPlaceholder={$i18n.t('No chats found for this user.')}
shareUrl={true}
{chatList}
+26 -7
View File
@@ -14,17 +14,20 @@
import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte';
import i18n from '$lib/i18n';
export let id = '';
let scrollEnd = true;
let messagesContainerElement = null;
let chatInputElement = null;
let top = false;
let channel = null;
let messages = null;
let replyToMessage = null;
let threadId = null;
let typingUsers = [];
@@ -140,16 +143,20 @@
return;
}
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
const res = await sendMessage(localStorage.token, id, {
content: content,
data: data,
reply_to_id: replyToMessage?.id ?? null
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
replyToMessage = null;
};
const onChange = async () => {
@@ -221,8 +228,14 @@
{#key id}
<Messages
{channel}
{messages}
{top}
{messages}
{replyToMessage}
onReply={async (message) => {
replyToMessage = message;
await tick();
chatInputElement?.focus();
}}
onThread={(id) => {
threadId = id;
}}
@@ -249,9 +262,15 @@
<div class=" pb-[1rem] px-2.5">
<MessageInput
id="root"
bind:chatInputElement
bind:replyToMessage
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this channel.')
: $i18n.t('Type here...')}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
+46 -7
View File
@@ -23,22 +23,25 @@
import { getSessionUser } from '$lib/apis/auths';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte';
import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Send a Message');
export let placeholder = $i18n.t('Type here...');
export let id = null;
export let chatInputElement;
@@ -53,12 +56,15 @@
export let scrollEnd = true;
export let scrollToBottom: Function = () => {};
export let disabled = false;
export let acceptFiles = true;
export let showFormattingToolbar = true;
export let userSuggestions = false;
export let channelSuggestions = false;
export let replyToMessage = null;
export let typingUsersClassName = 'from-white dark:from-gray-900';
let loaded = false;
@@ -731,7 +737,9 @@
</div>
</div>
<div class="">
<div
class="{disabled ? 'opacity-50 pointer-events-none cursor-not-allowed' : ''} relative z-20"
>
{#if recording}
<VoiceRecording
bind:recording
@@ -766,9 +774,36 @@
}}
>
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if replyToMessage !== null}
<div class="px-3 pt-3 text-left w-full flex flex-col z-10">
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm">
<div class="translate-y-[0.5px]">
<span class=""
>{$i18n.t('Replying to {{NAME}}', {
NAME: replyToMessage?.meta?.model_name ?? replyToMessage.user.name
})}</span
>
</div>
</div>
<div>
<button
class="flex items-center dark:text-gray-500"
on:click={() => {
replyToMessage = null;
}}
>
<XMark />
</button>
</div>
</div>
</div>
{/if}
{#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
@@ -836,6 +871,8 @@
bind:this={chatInputElement}
json={true}
messageInput={true}
editable={!disabled}
{placeholder}
richText={$settings?.richTextInput ?? true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
@@ -884,6 +921,7 @@
if (e.key === 'Escape') {
console.info('Escape');
replyToMessage = null;
}
}}
on:paste={async (e) => {
@@ -936,6 +974,7 @@
}}
>
<button
id="input-menu-button"
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
type="button"
aria-label="More"
@@ -54,7 +54,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
@@ -64,7 +64,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
+9 -1
View File
@@ -23,10 +23,12 @@
export let id = null;
export let channel = null;
export let messages = [];
export let replyToMessage = null;
export let top = false;
export let thread = false;
export let onLoad: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {};
let messagesLoading = false;
@@ -94,9 +96,12 @@
<Message
{message}
{thread}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access}
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id}
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message}
onDelete={() => {
messages = messages.filter((m) => m.id !== message.id);
@@ -122,6 +127,9 @@
return null;
});
}}
onReply={(message) => {
onReply(message);
}}
onThread={(id) => {
onThread(id);
}}
@@ -13,8 +13,9 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { formatDate } from '$lib/utils';
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
@@ -32,17 +33,20 @@
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte';
import { t } from 'i18next';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
export let message;
export let showUserProfile = true;
export let thread = false;
export let replyToMessage = false;
export let disabled = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {};
export let onReaction: Function = () => {};
@@ -64,11 +68,17 @@
{#if message}
<div
id="message-{message.id}"
class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5'
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''}"
>
{#if !edit}
{#if !edit && !disabled}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
@@ -94,6 +104,17 @@
</Tooltip>
</EmojiPicker>
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
@@ -133,6 +154,56 @@
</div>
{/if}
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-2 border-l-2 border-gray-300 dark:border-gray-500 z-0"
></div>
<button
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.classList.add('highlight');
setTimeout(() => {
messageElement.classList.remove('highlight');
}, 2000);
return;
}
}}
>
{#if message?.reply_to_message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
alt={message.reply_to_message.meta.model_name ??
message.reply_to_message.meta.model_id}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{:else}
<img
src={message.reply_to_message.user?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{/if}
<div class="shrink-0">
{message?.reply_to_message.meta?.model_name ??
message?.reply_to_message.user?.name ??
$i18n.t('Unknown User')}
</div>
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown id={`${message.id}-reply-to`} content={message?.reply_to_message?.content} />
</div>
</button>
</div>
{/if}
<div
class=" flex w-full message-{message.id}"
id="message-{message.id}"
@@ -150,7 +221,7 @@
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 translate-y-1 ml-0.5'}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>
{/if}
@@ -347,3 +418,18 @@
</div>
</div>
{/if}
<style>
.highlight {
animation: highlightAnimation 2s ease-in-out;
}
@keyframes highlightAnimation {
0% {
background-color: rgba(0, 60, 255, 0.1);
}
100% {
background-color: transparent;
}
}
</style>
+48 -22
View File
@@ -10,6 +10,7 @@
import Messages from './Messages.svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n');
@@ -21,11 +22,14 @@
let messages = null;
let top = false;
let messagesContainerElement = null;
let chatInputElement = null;
let replyToMessage = null;
let typingUsers = [];
let typingUsersTimeout = {};
let messagesContainerElement = null;
$: if (threadId) {
initHandler();
}
@@ -127,12 +131,15 @@
const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId,
reply_to_id: replyToMessage?.id ?? null,
content: content,
data: data
}).catch((error) => {
toast.error(`${error}`);
return null;
});
replyToMessage = null;
};
const onChange = async () => {
@@ -175,32 +182,51 @@
</div>
<div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
<Messages
id={threadId}
{channel}
{messages}
{top}
thread={true}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
messages.length
);
{#if messages !== null}
<Messages
id={threadId}
{channel}
{top}
{messages}
{replyToMessage}
thread={true}
onReply={async (message) => {
replyToMessage = message;
messages = [...messages, ...newMessages];
await tick();
chatInputElement?.focus();
}}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
messages.length
);
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
{:else}
<div class="w-full flex justify-center pt-5 pb-10">
<Spinner />
</div>
{/if}
<div class=" pb-[1rem] px-2.5 w-full">
<MessageInput
bind:replyToMessage
bind:chatInputElement
id={threadId}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this thread.')
: $i18n.t('Reply to thread...')}
typingUsersClassName="from-gray-50 dark:from-gray-850"
{typingUsers}
userSuggestions={true}
+18 -12
View File
@@ -38,7 +38,8 @@
toolServers,
functions,
selectedFolder,
pinnedChats
pinnedChats,
showEmbeds
} from '$lib/stores';
import {
convertMessagesToHistory,
@@ -362,6 +363,8 @@
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
} else if (type === 'chat:message:error') {
message.error = data.error;
} else if (type === 'chat:message:follow_ups') {
@@ -465,6 +468,15 @@
return;
}
if (event.data.type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(prompt);
}
}
// Replace with your iframe's origin
if (event.data.type === 'input:prompt') {
console.debug(event.data.text);
@@ -477,15 +489,6 @@
}
}
if (event.data.type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(prompt);
}
}
if (event.data.type === 'input:prompt:submit') {
console.debug(event.data.text);
@@ -562,6 +565,7 @@
showCallOverlay.set(false);
showOverview.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
}
});
@@ -1494,7 +1498,9 @@
const _files = JSON.parse(JSON.stringify(files));
chatFiles.push(
..._files.filter((item) => ['doc', 'text', 'file', 'collection'].includes(item.type))
..._files.filter((item) =>
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
)
);
chatFiles = chatFiles.filter(
// Remove duplicates
@@ -2228,7 +2234,7 @@
<svelte:head>
<title>
{$chatTitle
{$settings.showChatTitleInTab !== false && $chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
+18 -5
View File
@@ -4,7 +4,14 @@
import { Pane, PaneResizer } from 'paneforge';
import { onDestroy, onMount, tick } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview, showArtifacts } from '$lib/stores';
import {
mobile,
showControls,
showCallOverlay,
showOverview,
showArtifacts,
showEmbeds
} from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Controls from './Controls/Controls.svelte';
@@ -13,6 +20,7 @@
import Overview from './Overview.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
export let history;
export let models = [];
@@ -134,6 +142,7 @@
showControls.set(false);
showOverview.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
if ($showCallOverlay) {
showCallOverlay.set(false);
@@ -155,9 +164,9 @@
}}
>
<div
class=" {$showCallOverlay || $showOverview || $showArtifacts
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
? ' h-screen w-full'
: 'px-6 py-4'} h-full"
: 'px-4 py-3'} h-full"
>
{#if $showCallOverlay}
<div
@@ -175,6 +184,8 @@
}}
/>
</div>
{:else if $showEmbeds}
<Embeds />
{:else if $showArtifacts}
<Artifacts {history} />
{:else if $showOverview}
@@ -241,9 +252,9 @@
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
@@ -260,6 +271,8 @@
}}
/>
</div>
{:else if $showEmbeds}
<Embeds overlay={dragged} />
{:else if $showArtifacts}
<Artifacts {history} overlay={dragged} />
{:else if $showOverview}
@@ -0,0 +1,41 @@
<script>
import { embed, showControls, showEmbeds } from '$lib/stores';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let overlay = false;
</script>
{#if $embed}
<div class="h-full w-full">
<div
class="pointer-events-auto z-20 flex justify-between items-center py-3 px-2 font-primar text-gray-900 dark:text-white"
>
<div class="flex-1 flex items-center justify-between pl-2">
<div class="flex items-center space-x-2">
{$embed?.title ?? 'Embedded Content'}
</div>
</div>
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
on:click={() => {
showControls.set(false);
showEmbeds.set(false);
embed.set(null);
}}
>
<XMark className="size-3.5 text-gray-900 dark:text-white" />
</button>
</div>
<div class=" w-full h-full relative">
{#if overlay}
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if}
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" />
</div>
</div>
{/if}
+62 -14
View File
@@ -78,6 +78,8 @@
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
const i18n = getContext('i18n');
@@ -112,6 +114,10 @@
let inputVariables = {};
let inputVariableValues = {};
let showValvesModal = false;
let selectedValvesType = 'tool'; // 'tool' or 'function'
let selectedValvesItemId = null;
$: onChange({
prompt,
files: files
@@ -932,6 +938,16 @@
onSave={inputVariablesModalCallback}
/>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
{#if loaded}
<div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@@ -1030,9 +1046,10 @@
}}
>
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
: ' border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
: ' border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if atSelectedModel !== undefined}
@@ -1414,6 +1431,9 @@
console.error('OneDrive Error:', error);
}
}}
onUpload={async (e) => {
dispatch('upload', e);
}}
onClose={async () => {
await tick();
@@ -1422,13 +1442,16 @@
}}
>
<div
id="input-menu-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
>
<PlusAlt className="size-5.5" />
</div>
</InputMenu>
<div class="flex self-center w-[1px] h-4 mx-1 bg-gray-50 dark:bg-gray-800" />
<div
class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
/>
{#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
<IntegrationsMenu
@@ -1442,6 +1465,12 @@
bind:webSearchEnabled
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
onShowValves={(e) => {
const { type, id } = e;
selectedValvesType = type;
selectedValvesItemId = id;
showValvesModal = true;
}}
onClose={async () => {
await tick();
@@ -1450,6 +1479,7 @@
}}
>
<div
id="integration-menu-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
>
<Component className="size-4.5" strokeWidth="1.5" />
@@ -1457,6 +1487,24 @@
</IntegrationsMenu>
{/if}
{#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}
<div class="ml-1 flex gap-1.5">
<Tooltip content={$i18n.t('Valves')} placement="top">
<button
id="model-valves-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
on:click={() => {
selectedValvesType = 'function';
selectedValvesItemId = selectedModelIds[0]?.split('.')[0];
showValvesModal = true;
}}
>
<Knobs className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
{/if}
<div class="ml-1 flex gap-1.5">
{#if (selectedToolIds ?? []).length > 0}
<Tooltip
@@ -1492,11 +1540,11 @@
);
}}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes(
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
filterId
)
? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize"
? 'text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize"
>
{#if filter?.icon}
<div class="size-4 items-center flex justify-center">
@@ -1525,10 +1573,10 @@
<button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled ||
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always'
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
>
<GlobeAlt className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block">
@@ -1544,9 +1592,9 @@
on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
>
<Photo className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block">
@@ -1566,9 +1614,9 @@
on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button"
class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ??
class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} {($settings?.highContrastMode ??
false)
? 'm-1'
: 'focus:outline-hidden rounded-full'}"
@@ -0,0 +1,85 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
export let show = false;
export let onSubmit: (e) => void;
let url = '';
const submitHandler = () => {
if (isValidHttpUrl(url)) {
onSubmit({
type: isYoutubeUrl(url) ? 'youtube' : 'web',
data: url
});
show = false;
url = '';
} else {
toast.error($i18n.t('Please enter a valid URL.'));
}
};
</script>
<Modal bind:show size="sm">
<div class="flex flex-col h-full">
<div class="flex justify-between items-center dark:text-gray-100 px-5 pt-4 pb-1.5">
<h1 class="text-lg font-medium self-center font-primary">
{$i18n.t('Attach Webpage')}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;
}}
>
<XMark className="size-5" />
</button>
</div>
<div class="px-5 pb-4">
<form
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex justify-between mb-0.5">
<label
for="webpage-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Webpage URL')}</label
>
</div>
<input
id="webpage-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={'https://example.com'}
autocomplete="off"
required
/>
<div class="flex justify-end gap-2 pt-3 bg-gray-50 dark:bg-gray-900/50">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-200 transition rounded-full"
type="submit"
>
{$i18n.t('Add')}
</button>
</div>
</form>
</div>
</div>
</Modal>
@@ -7,7 +7,7 @@
dayjs.extend(relativeTime);
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Database from '$lib/components/icons/Database.svelte';
@@ -36,7 +36,7 @@
: items),
...(query.startsWith('http')
? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')
? isYoutubeUrl(query)
? [{ type: 'youtube', name: query, description: query }]
: [
{
@@ -228,7 +228,7 @@
{/if}
{/each}
{#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}
{#if isYoutubeUrl(query)}
<button
class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
type="button"
@@ -5,6 +5,8 @@
import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { createPicker } from '$lib/utils/google-drive-picker';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -24,6 +26,8 @@
import Chats from './InputMenu/Chats.svelte';
import Notes from './InputMenu/Notes.svelte';
import Knowledge from './InputMenu/Knowledge.svelte';
import AttachWebpageModal from './AttachWebpageModal.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
const i18n = getContext('i18n');
@@ -39,11 +43,14 @@
export let uploadGoogleDriveHandler: Function;
export let uploadOneDriveHandler: Function;
export let onUpload: Function;
export let onClose: Function;
let show = false;
let tab = '';
let showAttachWebpageModal = false;
let fileUploadEnabled = true;
$: fileUploadEnabled =
fileUploadCapableModels.length === selectedModels.length &&
@@ -62,6 +69,16 @@
}
};
const init = async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
}
};
$: if (show) {
init();
}
const onSelect = (item) => {
if (files.find((f) => f.id === item.id)) {
return;
@@ -78,6 +95,13 @@
};
</script>
<AttachWebpageModal
bind:show={showAttachWebpageModal}
onSubmit={(e) => {
onUpload(e);
}}
/>
<!-- Hidden file input used to open the camera on mobile -->
<input
id="camera-input"
@@ -120,7 +144,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -144,7 +168,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -166,6 +190,27 @@
</DropdownMenu.Item>
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
if (fileUploadEnabled) {
showAttachWebpageModal = true;
}
}}
>
<GlobeAlt />
<div class="line-clamp-1">{$i18n.t('Attach Webpage')}</div>
</DropdownMenu.Item>
</Tooltip>
{#if $config?.features?.enable_notes ?? false}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
@@ -176,7 +221,7 @@
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -198,35 +243,37 @@
</Tooltip>
{/if}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
{#if ($knowledge ?? []).length > 0}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<Database />
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
>
<Database />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
</div>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
</div>
<div class="text-gray-500">
<ChevronRight />
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</div>
</button>
</Tooltip>
</button>
</Tooltip>
{/if}
{#if ($chats ?? []).length > 0}
<Tooltip
@@ -238,7 +285,7 @@
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -263,12 +310,12 @@
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadGoogleDriveHandler();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-4">
<path
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
fill="#0066da"
@@ -299,140 +346,117 @@
{/if}
{#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'microsoft_onedrive';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
class="size-4"
fill="none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
class="w-5 h-5"
fill="none"
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
>
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
side={$mobile ? 'bottom' : 'right'}
sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8}
>
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('personal');
}}
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</div>
</DropdownMenu.Item>
{/if}
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">
{$i18n.t('Microsoft OneDrive (work/school)')}
</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Microsoft OneDrive')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
{/if}
{/if}
</div>
{:else if tab === 'knowledge'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -451,7 +475,7 @@
{:else if tab === 'notes'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -470,7 +494,7 @@
{:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -486,6 +510,52 @@
<Chats {onSelect} />
</div>
{:else if tab === 'microsoft_onedrive'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Microsoft OneDrive')}
</div>
</div>
</button>
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('personal');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">
{$i18n.t('Microsoft OneDrive (work/school)')}
</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
{/if}
</div>
{/if}
</DropdownMenu.Content>
</div>

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