Compare commits

...
200 Commits
Author SHA1 Message Date
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
169 changed files with 7583 additions and 2576 deletions
+54
View File
@@ -5,6 +5,60 @@ 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.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 💬
+19 -7
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
@@ -2168,6 +2170,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 +2179,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 +2767,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
####################################
+1 -1
View File
@@ -239,7 +239,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),
)
+104 -11
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:
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,14 +1891,78 @@ async def get_current_usage(user=Depends(get_verified_user)):
# OAuth Login & Callback
############################
# 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)
)
# SessionMiddleware is used by authlib for oauth
if len(OAUTH_PROVIDERS) > 0:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="oui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
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="oui-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",
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,
)
@@ -1885,8 +1977,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)
+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
+16 -1
View File
@@ -492,11 +492,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:
@@ -943,6 +948,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:
+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": []}
@@ -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 []
+49 -11
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",
}
)
############################
@@ -275,6 +290,7 @@ async def model_response_handler(request, channel, message, user):
)
thread_history = []
images = []
message_users = {}
for thread_message in thread_messages:
@@ -303,6 +319,11 @@ 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."
@@ -313,14 +334,29 @@ async def model_response_handler(request, channel, message, user):
),
}
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 +398,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()
@@ -658,7 +694,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()
@@ -724,7 +760,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()
@@ -806,7 +842,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()
+7 -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(
+169 -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,105 @@ 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)
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:
raise HTTPException(
status_code=400,
detail=f"Failed to fetch 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
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]}",
)
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
raise HTTPException(
status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
)
else:
try:
client = MCPClient()
headers = None
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",
)
+8 -8
View File
@@ -262,15 +262,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,
+4 -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
+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
)
+224 -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
@@ -469,6 +471,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 +528,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 +992,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 +1146,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 +1415,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 +1454,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={
**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 +1794,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,
+61 -5
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,
@@ -41,8 +42,17 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = Tools.get_tools()
tools = [
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": "class UserValves(BaseModel):" in tool.content,
}
)
for tool in Tools.get_tools()
]
# OpenAPI Tool Servers
for server in await get_tool_servers(request):
tools.append(
ToolUserResponse(
@@ -68,6 +78,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 +516,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 +593,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(
+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()
+322 -91
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 (
@@ -144,12 +152,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,
@@ -631,48 +641,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 +716,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 +834,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 +1003,107 @@ 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_client = MCPClient()
await mcp_client.connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
tool_specs = await mcp_client.list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(function_name):
async def tool_function(**kwargs):
return await mcp_client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
tool_function = make_tool_function(tool_spec["name"])
mcp_tools_dict[tool_spec["name"]] = {
"spec": tool_spec,
"callable": tool_function,
"type": "mcp",
"client": mcp_client,
"direct": False,
}
mcp_clients.append(mcp_client)
except Exception as e:
log.debug(e)
continue
tools_dict = await get_tools(
request,
tool_ids,
@@ -1006,9 +1115,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 +1129,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
@@ -1026,6 +1140,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
@@ -1079,26 +1194,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 = [
@@ -1498,7 +1602,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 +1685,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,6 +2433,8 @@ 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_args = tool_call.get("function", {}).get("arguments", "{}")
@@ -2402,14 +2509,145 @@ async def process_chat_response(
except Exception as e:
tool_result = str(e)
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": "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"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"Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (
tool.get("type") == "external"
and isinstance(tool_result, tuple)
) or (
tool.get("direct", True)
and isinstance(tool_result, list)
and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
if tool_response_headers:
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": "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": "Embedded UI result is active and visible to the user.",
}
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_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if tool.get("type") == "mcp":
if isinstance(item, dict):
if (
item.get("type") == "image"
or item.get("type") == "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.remove(item)
if tool_result_files:
if not isinstance(tool_result, list):
tool_result = [
tool_result,
]
for file in tool_result_files:
tool_result.append(
{
"type": file.get("type", "data"),
"content": "Result is being displayed as a file.",
}
)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
@@ -2426,6 +2664,11 @@ async def process_chat_response(
if tool_result_files
else {}
),
**(
{"embeds": tool_result_embeds}
if tool_result_embeds
else {}
),
}
)
@@ -2571,23 +2814,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 +2835,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 -19
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
+476 -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,412 @@ 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]:
urls = []
parsed, base_url = get_parsed_and_base_url(server_url)
urls.append(
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server")
)
urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"))
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 +621,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 +650,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 +718,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 +768,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 +1041,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 +1258,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:
+118 -86
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:
@@ -577,7 +602,10 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
# Prepare list of enabled servers along with their original index
server_entries = []
for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"):
if (
server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi"
):
# 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)
@@ -646,7 +674,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 +746,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 +757,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 +776,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.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.30",
"version": "0.6.31",
"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.31",
"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;
}
+9 -1
View File
@@ -77,7 +77,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 +89,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: {
+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;
+15 -8
View File
@@ -493,18 +493,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];
}
};
@@ -13,7 +13,7 @@
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 { 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';
@@ -30,6 +30,8 @@
let url = '';
let path = 'openapi.json';
let type = 'openapi'; // 'openapi', 'mcp'
let auth_type = 'bearer';
let key = '';
@@ -39,10 +41,47 @@
let name = '';
let description = '';
let enable = true;
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'));
@@ -70,6 +109,7 @@
const res = await verifyToolServerConnection(localStorage.token, {
url,
path,
type,
auth_type,
key,
config: {
@@ -97,10 +137,22 @@
// 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;
}
const connection = {
url,
path,
type,
auth_type,
key,
config: {
@@ -110,7 +162,8 @@
info: {
id: id,
name: name,
description: description
description: description,
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
}
};
@@ -119,14 +172,18 @@
loading = false;
show = false;
// reset form
url = '';
path = 'openapi.json';
type = 'openapi';
key = '';
auth_type = 'bearer';
id = '';
name = '';
description = '';
oauthClientInfo = null;
enable = true;
accessControl = null;
@@ -137,12 +194,14 @@
url = connection.url;
path = connection?.path ?? 'openapi.json';
type = connection?.type ?? 'openapi';
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;
@@ -189,6 +248,31 @@
}}
>
<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">
@@ -243,38 +327,85 @@
</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>
{#if ['', 'openapi'].includes(type)}
<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>
{/if}
</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>
{#if ['', 'openapi'].includes(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 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 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">
@@ -290,6 +421,9 @@
{#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>
@@ -319,6 +453,12 @@
>
{$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>
@@ -334,9 +474,12 @@
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
>
{#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">
@@ -347,6 +490,7 @@
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
@@ -396,13 +540,32 @@
<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>
{/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-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
+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"
@@ -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>
@@ -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">
@@ -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"
@@ -43,6 +43,10 @@
let searchDebounceTimeout;
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
@@ -14,6 +14,7 @@
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 = '';
@@ -252,6 +253,10 @@
{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}
@@ -38,7 +38,7 @@
import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte';
export let placeholder = $i18n.t('Send a Message');
export let placeholder = $i18n.t('Type here...');
export let id = null;
export let chatInputElement;
@@ -53,6 +53,7 @@
export let scrollEnd = true;
export let scrollToBottom: Function = () => {};
export let disabled = false;
export let acceptFiles = true;
export let showFormattingToolbar = true;
@@ -731,7 +732,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,6 +769,7 @@
}}
>
<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'}
>
@@ -836,6 +840,8 @@
bind:this={chatInputElement}
json={true}
messageInput={true}
editable={!disabled}
{placeholder}
richText={$settings?.richTextInput ?? true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
@@ -936,6 +942,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();
}}
@@ -94,6 +94,7 @@
<Message
{message}
{thread}
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}
@@ -40,6 +40,7 @@
export let message;
export let showUserProfile = true;
export let thread = false;
export let disabled = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
@@ -68,7 +69,7 @@
? '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"
>
{#if !edit}
{#if !edit && !disabled}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
+31 -20
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');
@@ -175,32 +176,42 @@
</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}
{messages}
{top}
thread={true}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
messages.length
);
messages = [...messages, ...newMessages];
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
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
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}
+12 -10
View File
@@ -465,6 +465,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 +486,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);
@@ -1494,7 +1494,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
+10 -2
View File
@@ -1030,9 +1030,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 +1415,9 @@
console.error('OneDrive Error:', error);
}
}}
onUpload={async (e) => {
dispatch('upload', e);
}}
onClose={async () => {
await tick();
@@ -1422,13 +1426,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
@@ -1450,6 +1457,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" />
@@ -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>
@@ -31,7 +31,7 @@
const getItemsPage = async () => {
itemsLoading = true;
let res = await getChatList(localStorage.token, page).catch(() => {
let res = await getChatList(localStorage.token, page, true).catch(() => {
return [];
});
@@ -19,6 +19,9 @@
import Terminal from '$lib/components/icons/Terminal.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ValvesModal from '$lib/components/workspace/common/ValvesModal.svelte';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { partition } from 'd3-hierarchy';
const i18n = getContext('i18n');
@@ -43,6 +46,11 @@
let show = false;
let tab = '';
let showValvesModal = false;
let selectedValvesType = 'tool';
let selectedValvesItemId = null;
let tools = null;
$: if (show) {
@@ -64,7 +72,8 @@
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id)
enabled: selectedToolIds.includes(tool.id),
...tool
};
return a;
}, {});
@@ -87,6 +96,16 @@
};
</script>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
<Dropdown
bind:show
on:change={(e) => {
@@ -112,7 +131,7 @@
{#if tools}
{#if Object.keys(tools).length > 0}
<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 = 'tools';
}}
@@ -141,7 +160,7 @@
{#each toggleFilters.sort( (a, b) => a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } ) ) as filter, filterIdx (filter.id)}
<Tooltip content={filter?.description} placement="top-start">
<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={() => {
if (selectedFilterIds.includes(filter.id)) {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
@@ -190,7 +209,7 @@
{#if showWebSearchButton}
<Tooltip content={$i18n.t('Search the internet')} placement="top-start">
<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={() => {
webSearchEnabled = !webSearchEnabled;
}}
@@ -221,7 +240,7 @@
{#if showImageGenerationButton}
<Tooltip content={$i18n.t('Generate an image')} placement="top-start">
<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={() => {
imageGenerationEnabled = !imageGenerationEnabled;
}}
@@ -252,7 +271,7 @@
{#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top-start">
<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"
aria-pressed={codeInterpreterEnabled}
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
@@ -287,7 +306,7 @@
{:else if tab === 'tools' && tools}
<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 = '';
}}
@@ -304,11 +323,25 @@
{#each Object.keys(tools) as toolId}
<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"
on:click={() => {
tools[toolId].enabled = !tools[toolId].enabled;
class="relative 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={(e) => {
if (!(tools[toolId]?.authenticated ?? true)) {
e.preventDefault();
let parts = toolId.split(':');
let serverId = parts?.at(-1) ?? toolId;
const authUrl = getOAuthClientAuthorizationUrl(serverId, 'mcp');
window.open(authUrl, '_blank', 'noopener');
} else {
tools[toolId].enabled = !tools[toolId].enabled;
}
}}
>
{#if !(tools[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-not-allowed z-10" />
{/if}
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools[toolId]?.name ?? ''} placement="top">
@@ -322,6 +355,44 @@
</div>
</div>
{#if tools[toolId]?.has_user_valves}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedValvesType = 'tool';
selectedValvesItemId = toolId;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0">
<Switch
state={tools[toolId].enabled}
@@ -1,16 +1,18 @@
<script lang="ts">
import hljs from 'highlight.js';
import { toast } from 'svelte-sonner';
import { getContext, onMount, tick, onDestroy } from 'svelte';
import { config } from '$lib/stores';
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import { executeCode } from '$lib/apis/utils';
import { copyToClipboard, renderMermaidDiagram } from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css';
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
import { config } from '$lib/stores';
import { executeCode } from '$lib/apis/utils';
import { toast } from 'svelte-sonner';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronUpDown from '$lib/components/icons/ChevronUpDown.svelte';
import CommandLine from '$lib/components/icons/CommandLine.svelte';
@@ -480,19 +482,17 @@
{#if !collapsed}
{#if edit}
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
<CodeEditor
value={code}
{id}
{lang}
onSave={() => {
saveCode();
}}
onChange={(value) => {
_code = value;
}}
/>
{/await}
<CodeEditor
value={code}
{id}
{lang}
onSave={() => {
saveCode();
}}
onChange={(value) => {
_code = value;
}}
/>
{:else}
<pre
class=" hljs p-4 px-5 overflow-x-auto"
@@ -286,9 +286,11 @@
}
};
onMount(async () => {
const setOllamaVersion = async () => {
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
};
onMount(async () => {
if (items) {
tags = items
.filter((item) => !(item.model?.info?.meta?.hidden ?? false))
@@ -300,6 +302,10 @@
}
});
$: if (show) {
setOllamaVersion();
}
const cancelModelPullHandler = async (model: string) => {
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
if (abortController) {
@@ -45,7 +45,7 @@
</script>
<div id="tab-about" class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center">
<div>
@@ -117,7 +117,7 @@
</script>
<div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<input
id="profile-image-input"
bind:this={profileImageInputElement}
@@ -175,7 +175,7 @@
dispatch('save');
}}
>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>
@@ -117,7 +117,7 @@
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="flex flex-col">
<input
id="chat-import-input"
@@ -191,7 +191,7 @@
</script>
<div class="flex flex-col h-full justify-between text-sm" id="tab-general">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="">
<div class=" mb-1 text-sm font-medium">{$i18n.t('WebUI Settings')}</div>
@@ -277,7 +277,7 @@
</div>
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
<hr class="border-gray-50 dark:border-gray-850 my-3" />
<hr class="border-gray-100/50 dark:border-gray-850 my-3" />
<div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
@@ -285,8 +285,8 @@
bind:value={system}
className={'w-full text-sm outline-hidden resize-vertical' +
($settings.highContrastMode
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' bg-white dark:text-gray-300 dark:bg-gray-900')}
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' dark:text-gray-300 ')}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>
@@ -306,7 +306,7 @@
}}
/>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<h1 class=" mb-2 text-sm font-medium">{$i18n.t('UI')}</h1>
@@ -30,7 +30,7 @@
dispatch('save');
}}
>
<div class="py-1 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class="py-1 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div>
<div class="flex items-center justify-between mb-1">
<Tooltip
@@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let saveSettings: Function;
@@ -52,7 +52,7 @@
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct />
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct />
<form
id="tab-tools"
@@ -6,7 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
@@ -18,7 +18,7 @@
let showDeleteConfirmDialog = false;
</script>
<AddServerModal
<AddToolServerModal
edit
{direct}
bind:show={showConfigModal}
@@ -48,15 +48,12 @@
})}
placement="top-start"
>
{#if !(connection?.config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<div class="flex w-full">
<div class="flex-1 relative">
<input
class=" outline-hidden w-full bg-transparent"
class=" outline-hidden w-full bg-transparent {!(connection?.config?.enable ?? true)
? 'opacity-50'
: ''}"
placeholder={$i18n.t('API Base URL')}
bind:value={connection.url}
autocomplete="off"
+20 -17
View File
@@ -569,9 +569,9 @@
});
</script>
<Modal size="lg" bind:show>
<Modal size="xl" bind:show>
<div class="text-gray-700 dark:text-gray-100 mx-1">
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class=" flex justify-between dark:text-gray-300 px-4 md:px-4.5 pt-4.5 pb-0.5 md:pb-2.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>
<button
aria-label={$i18n.t('Close settings modal')}
@@ -584,13 +584,16 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pt-1 pb-4 md:space-x-4">
<div class="flex flex-col md:flex-row w-full pt-1 pb-4">
<div
role="tablist"
id="settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 md:gap-1 md:flex-col flex-1 md:flex-none md:w-40 md:min-h-[32rem] md:max-h-[32rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[36rem] md:max-h-[36rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
>
<div class="hidden md:flex w-full rounded-xl -mb-1 px-[0.5px] gap-2" id="settings-search">
<div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 mb-1.5"
id="settings-search"
>
<div class="self-center rounded-l-xl bg-transparent">
<Search
className="size-3.5"
@@ -599,7 +602,7 @@
</div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input
class={`w-full py-1.5 text-sm bg-transparent dark:text-gray-300 outline-hidden
class={`w-full py-1 text-sm bg-transparent dark:text-gray-300 outline-hidden
${($settings?.highContrastMode ?? false) ? 'placeholder-gray-800' : ''}`}
bind:value={search}
id="search-input-settings-modal"
@@ -614,7 +617,7 @@
role="tab"
aria-controls="tab-general"
aria-selected={selectedTab === 'general'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'general'
? ($settings?.highContrastMode ?? false)
@@ -638,7 +641,7 @@
role="tab"
aria-controls="tab-interface"
aria-selected={selectedTab === 'interface'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'interface'
? ($settings?.highContrastMode ?? false)
@@ -663,7 +666,7 @@
role="tab"
aria-controls="tab-connections"
aria-selected={selectedTab === 'connections'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'connections'
? ($settings?.highContrastMode ?? false)
@@ -689,7 +692,7 @@
role="tab"
aria-controls="tab-tools"
aria-selected={selectedTab === 'tools'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'tools'
? ($settings?.highContrastMode ?? false)
@@ -714,7 +717,7 @@
role="tab"
aria-controls="tab-personalization"
aria-selected={selectedTab === 'personalization'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'personalization'
? ($settings?.highContrastMode ?? false)
@@ -738,7 +741,7 @@
role="tab"
aria-controls="tab-audio"
aria-selected={selectedTab === 'audio'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'audio'
? ($settings?.highContrastMode ?? false)
@@ -762,7 +765,7 @@
role="tab"
aria-controls="tab-data-controls"
aria-selected={selectedTab === 'data_controls'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'data_controls'
? ($settings?.highContrastMode ?? false)
@@ -786,7 +789,7 @@
role="tab"
aria-controls="tab-account"
aria-selected={selectedTab === 'account'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'account'
? ($settings?.highContrastMode ?? false)
@@ -810,7 +813,7 @@
role="tab"
aria-controls="tab-about"
aria-selected={selectedTab === 'about'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition
class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${
selectedTab === 'about'
? ($settings?.highContrastMode ?? false)
@@ -839,7 +842,7 @@
{#if $user?.role === 'admin'}
<a
href="/admin/settings"
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none md:mt-auto flex text-left transition {$settings?.highContrastMode
class="px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none md:mt-auto flex text-left transition {$settings?.highContrastMode
? 'hover:bg-gray-200 dark:hover:bg-gray-800'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={async (e) => {
@@ -855,7 +858,7 @@
</a>
{/if}
</div>
<div class="flex-1 md:min-h-[32rem] max-h-[32rem]">
<div class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[36rem] max-h-[36rem]">
{#if selectedTab === 'general'}
<General
{getModels}
+205 -134
View File
@@ -38,6 +38,8 @@
import CodeBlock from '../chat/Messages/CodeBlock.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
export let open = false;
@@ -87,54 +89,56 @@
</script>
<div {id} class={className}>
{#if title !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
<div class="">
{#if attributes?.type === 'reasoning'}
{#if attributes?.done === 'true' && attributes?.duration}
{#if attributes.duration < 1}
{$i18n.t('Thought for less than a second')}
{:else if attributes.duration < 60}
{$i18n.t('Thought for {{DURATION}} seconds', {
DURATION: attributes.duration
})}
{:else}
{$i18n.t('Thought for {{DURATION}}', {
DURATION: dayjs.duration(attributes.duration, 'seconds').humanize()
})}
{/if}
{:else}
{$i18n.t('Thinking...')}
{/if}
{:else if attributes?.type === 'code_interpreter'}
{#if attributes?.done === 'true'}
{$i18n.t('Analyzed')}
{:else}
{$i18n.t('Analyzing...')}
{/if}
{:else if attributes?.type === 'tool_calls'}
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<div class="py-1 w-full cursor-pointer">
<div class=" w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
@@ -150,99 +154,50 @@
})}
/>
{/if}
{:else}
{title}
{/if}
</div>
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:click={(e) => {
e.stopPropagation();
}}
on:pointerup={(e) => {
if (!disabled) {
open = !open;
}
}}
>
<div>
<div class="flex items-start justify-between">
<slot />
{#if chevron}
<div class="flex self-start translate-y-1">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
{/if}
</div>
{#if grow}
{#if open && !hide}
<div
transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<slot name="content" />
</div>
{/if}
{/if}
</div>
</div>
{/if}
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> ${formatJSONString(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
/>
{/if}
{:else}
<slot name="content" />
{/if}
{:else}
<slot name="content" />
{/if}
</div>
</div>
{/if}
{/if}
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
@@ -250,15 +205,131 @@
alt="Image"
/>
{/if}
{/each}
{/if}
{:else if typeof file === 'object'}
{#if file.type === 'image' && file.url}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if}
{/if}
{:else if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
{:else}
{#if title !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.type === 'reasoning'}
{#if attributes?.done === 'true' && attributes?.duration}
{#if attributes.duration < 1}
{$i18n.t('Thought for less than a second')}
{:else if attributes.duration < 60}
{$i18n.t('Thought for {{DURATION}} seconds', {
DURATION: attributes.duration
})}
{:else}
{$i18n.t('Thought for {{DURATION}}', {
DURATION: dayjs.duration(attributes.duration, 'seconds').humanize()
})}
{/if}
{:else}
{$i18n.t('Thinking...')}
{/if}
{:else if attributes?.type === 'code_interpreter'}
{#if attributes?.done === 'true'}
{$i18n.t('Analyzed')}
{:else}
{$i18n.t('Analyzing...')}
{/if}
{:else}
{title}
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:click={(e) => {
e.stopPropagation();
}}
on:pointerup={(e) => {
if (!disabled) {
open = !open;
}
}}
>
<div>
<div class="flex items-start justify-between">
<slot />
{#if chevron}
<div class="flex self-start translate-y-1">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
{/if}
</div>
{#if grow}
{#if open && !hide}
<div
transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<slot name="content" />
</div>
{/if}
{/if}
</div>
</div>
{/if}
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
</div>
{/if}
{/if}
{/if}
</div>
+2 -1
View File
@@ -139,7 +139,8 @@
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full group rounded-lg relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 transition {buttonClassName}"
id="sidebar-folder-button"
class=" w-full group rounded-xl relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 transition {buttonClassName}"
>
<button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium">
{#if chevron}
@@ -0,0 +1,199 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
// Props
export let src: string | null = null; // URL or raw HTML (auto-detected)
export let title = 'Embedded Content';
export let initialHeight: number | null = null; // initial height in px, null = auto
export let args = null;
export let allowScripts = true;
export let allowForms = false;
export let allowSameOrigin = false; // set to true only when you trust the content
export let allowPopups = false;
export let allowDownloads = true;
export let referrerPolicy: HTMLIFrameElement['referrerPolicy'] =
'strict-origin-when-cross-origin';
export let allowFullscreen = true;
let iframe: HTMLIFrameElement | null = null;
let iframeSrc: string | null = null;
let iframeDoc: string | null = null;
// Derived: build sandbox attribute from flags
$: sandbox =
[
allowScripts && 'allow-scripts',
allowForms && 'allow-forms',
allowSameOrigin && 'allow-same-origin',
allowPopups && 'allow-popups',
allowDownloads && 'allow-downloads'
]
.filter(Boolean)
.join(' ') || undefined;
// Detect URL vs raw HTML and prep src/srcdoc
$: isUrl = typeof src === 'string' && /^(https?:)?\/\//i.test(src);
$: if (src) {
setIframeSrc();
}
const setIframeSrc = async () => {
await tick();
if (isUrl) {
iframeSrc = src as string;
iframeDoc = null;
} else {
iframeDoc = await processHtmlForDeps(src as string);
iframeSrc = null;
}
};
// Alpine directives detection
const alpineDirectives = [
'x-data',
'x-init',
'x-show',
'x-bind',
'x-on',
'x-text',
'x-html',
'x-model',
'x-modelable',
'x-ref',
'x-for',
'x-if',
'x-effect',
'x-transition',
'x-cloak',
'x-ignore',
'x-teleport',
'x-id'
];
async function processHtmlForDeps(html: string): Promise<string> {
if (!allowSameOrigin) return html;
const scriptTags: string[] = [];
// --- Alpine.js detection & injection ---
const hasAlpineDirectives = alpineDirectives.some((dir) => html.includes(dir));
if (hasAlpineDirectives) {
try {
const { default: alpineCode } = await import('alpinejs/dist/cdn.min.js?raw');
const alpineBlob = new Blob([alpineCode], { type: 'text/javascript' });
const alpineUrl = URL.createObjectURL(alpineBlob);
const alpineTag = `<script src="${alpineUrl}" defer><\/script>`;
scriptTags.push(alpineTag);
} catch (error) {
console.error('Error processing Alpine for iframe:', error);
}
}
// --- Chart.js detection & injection ---
const chartJsDirectives = ['new Chart(', 'Chart.'];
const hasChartJsDirectives = chartJsDirectives.some((dir) => html.includes(dir));
if (hasChartJsDirectives) {
try {
// import chartUrl from 'chart.js/auto?url';
const { default: Chart } = await import('chart.js/auto');
(window as any).Chart = Chart;
const chartTag = `<script>
window.Chart = parent.Chart; // Chart previously assigned on parent
<\/script>`;
scriptTags.push(chartTag);
} catch (error) {
console.error('Error processing Chart.js for iframe:', error);
}
}
// If nothing to inject, return original HTML
if (scriptTags.length === 0) return html;
const tags = scriptTags.join('\n');
// Prefer injecting into <head>, then before </body>, otherwise prepend
if (html.includes('</head>')) {
return html.replace('</head>', `${tags}\n</head>`);
}
if (html.includes('</body>')) {
return html.replace('</body>', `${tags}\n</body>`);
}
return `${tags}\n${html}`;
}
// Try to measure same-origin content safely
function resizeSameOrigin() {
if (!iframe) return;
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
console.log('iframe doc:', doc);
if (!doc) return;
const h = Math.max(doc.documentElement?.scrollHeight ?? 0, doc.body?.scrollHeight ?? 0);
if (h > 0) iframe.style.height = h + 20 + 'px';
} catch {
// Cross-origin → rely on postMessage from inside the iframe
}
}
// Handle height messages from the iframe (we also verify the sender)
function onMessage(e: MessageEvent) {
if (!iframe || e.source !== iframe.contentWindow) return;
const data = e.data as { type?: string; height?: number };
if (data?.type === 'iframe:height' && typeof data.height === 'number') {
iframe.style.height = Math.max(0, data.height) + 'px';
}
}
// When the iframe loads, try same-origin resize (cross-origin will noop)
const onLoad = async () => {
requestAnimationFrame(resizeSameOrigin);
// if arguments are provided, inject them into the iframe window
if (args && iframe?.contentWindow) {
(iframe.contentWindow as any).args = args;
}
};
// Ensure event listener bound only while component lives
onMount(() => {
window.addEventListener('message', onMessage);
});
onDestroy(() => {
window.removeEventListener('message', onMessage);
});
</script>
{#if iframeDoc}
<iframe
bind:this={iframe}
srcdoc={iframeDoc}
{title}
class="w-full rounded-2xl"
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"
{sandbox}
{allowFullscreen}
on:load={onLoad}
/>
{:else if iframeSrc}
<iframe
bind:this={iframe}
src={iframeSrc}
{title}
class="w-full rounded-2xl"
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"
{sandbox}
referrerpolicy={referrerPolicy}
{allowFullscreen}
on:load={onLoad}
/>
{/if}
+15 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, getContext } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import fileSaver from 'file-saver';
@@ -11,6 +11,8 @@
export let src = '';
export let alt = '';
const i18n = getContext('i18n');
let mounted = false;
let previewElement = null;
@@ -100,9 +102,10 @@
const mimeType = blob.type || 'image/png';
// create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}`
: 'download.png';
const fileName = `${$i18n
.t('Generated Image')
.toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob
saveAs(blob, fileName);
@@ -119,9 +122,10 @@
const blobWithType = new Blob([blob], { type: mimeType });
// create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}`
: 'download.png';
const fileName = `${$i18n
.t('Generated Image')
.toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob
saveAs(blobWithType, fileName);
@@ -146,9 +150,10 @@
const blobWithType = new Blob([blob], { type: mimeType });
// create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}`
: 'download.png';
const fileName = `${$i18n
.t('Generated Image')
.toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob
saveAs(blobWithType, fileName);
+3 -3
View File
@@ -7,7 +7,7 @@
export let show = true;
export let size = 'md';
export let containerClassName = 'p-3';
export let className = 'bg-white dark:bg-gray-900 rounded-3xl';
export let className = 'bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm rounded-4xl';
let modalElement = null;
let mounted = false;
@@ -87,7 +87,7 @@
bind:this={modalElement}
aria-modal="true"
role="dialog"
class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] {containerClassName} flex justify-center z-9999 overflow-y-auto overscroll-contain"
class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/30 dark:bg-black/60 w-full h-screen max-h-[100dvh] {containerClassName} flex justify-center z-9999 overflow-y-auto overscroll-contain"
in:fade={{ duration: 10 }}
on:mousedown={() => {
show = false;
@@ -96,7 +96,7 @@
<div
class="m-auto max-w-full {sizeToWidth(size)} {size !== 'full'
? 'mx-2'
: ''} shadow-3xl min-h-fit scrollbar-hidden {className}"
: ''} shadow-3xl min-h-fit scrollbar-hidden {className} border border-white dark:border-gray-850"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();
+58 -345
View File
@@ -114,19 +114,6 @@
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor, Extension, mergeAttributes } from '@tiptap/core';
// Yjs imports
import * as Y from 'yjs';
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
prosemirrorJSONToYDoc,
yDocToProsemirrorJSON
} from 'y-prosemirror';
import { keymap } from 'prosemirror-keymap';
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
import StarterKit from '@tiptap/starter-kit';
@@ -152,16 +139,27 @@
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight';
import { createLowlight } from 'lowlight';
import hljs from 'highlight.js';
import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration';
export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {};
export let onChange = (e) => {};
// create a lowlight instance with all languages loaded
const lowlight = createLowlight(all);
const lowlight = createLowlight(
hljs.listLanguages().reduce(
(obj, lang) => {
obj[lang] = () => hljs.getLanguage(lang);
return obj;
},
{} as Record<string, any>
)
);
export let editor = null;
export let editor: Editor | null = null;
export let socket = null;
export let user = null;
@@ -170,7 +168,7 @@
export let documentId = '';
export let className = 'input-prose';
export let placeholder = 'Type here...';
export let placeholder = $i18n.t('Type here...');
let _placeholder = placeholder;
$: if (placeholder !== _placeholder) {
@@ -185,6 +183,7 @@
};
export let richText = true;
export let dragHandle = false;
export let link = false;
export let image = false;
export let fileHandler = false;
@@ -264,325 +263,11 @@
let jsonValue = '';
let mdValue = '';
let lastSelectionBookmark = null;
let provider: SocketIOCollaborationProvider | null = null;
// Yjs setup
let ydoc = null;
let yXmlFragment = null;
let awareness = null;
const getEditorInstance = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(editor);
}, 0);
});
};
// Custom Yjs Socket.IO provider
class SocketIOProvider {
constructor(doc, documentId, socket, user) {
this.doc = doc;
this.documentId = documentId;
this.socket = socket;
this.user = user;
this.isConnected = false;
this.synced = false;
this.setupEventListeners();
}
generateUserColor() {
const colors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
];
return colors[Math.floor(Math.random() * colors.length)];
}
joinDocument() {
const userColor = this.generateUserColor();
this.socket.emit('ydoc:document:join', {
document_id: this.documentId,
user_id: this.user?.id,
user_name: this.user?.name,
user_color: userColor
});
// Set user awareness info
if (awareness && this.user) {
awareness.setLocalStateField('user', {
name: `${this.user.name}`,
color: userColor,
id: this.socket.id
});
}
}
setupEventListeners() {
// Listen for document updates from server
this.socket.on('ydoc:document:update', (data) => {
if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
try {
const update = new Uint8Array(data.update);
Y.applyUpdate(this.doc, update);
} catch (error) {
console.error('Error applying Yjs update:', error);
}
}
});
// Listen for document state from server
this.socket.on('ydoc:document:state', async (data) => {
if (data.document_id === this.documentId) {
try {
if (data.state) {
const state = new Uint8Array(data.state);
if (state.length === 2 && state[0] === 0 && state[1] === 0) {
// Empty state, check if we have content to initialize
// check if editor empty as well
// const editor = await getEditorInstance();
const isEmptyEditor = !editor || editor.getText().trim() === '';
if (isEmptyEditor) {
if (content && (data?.sessions ?? ['']).length === 1) {
const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content);
if (editorYdoc) {
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
}
}
} else {
// If the editor already has content, we don't need to send an empty state
if (this.doc.getXmlFragment('prosemirror').length > 0) {
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Y.encodeStateAsUpdate(this.doc)
});
} else {
console.warn('Yjs document is empty, not sending state.');
}
}
} else {
Y.applyUpdate(this.doc, state, 'server');
}
}
this.synced = true;
} catch (error) {
console.error('Error applying Yjs state:', error);
this.synced = false;
this.socket.emit('ydoc:document:state', {
document_id: this.documentId
});
}
}
});
// Listen for awareness updates
this.socket.on('ydoc:awareness:update', (data) => {
if (data.document_id === this.documentId && awareness) {
try {
const awarenessUpdate = new Uint8Array(data.update);
awareness.applyUpdate(awarenessUpdate, 'server');
} catch (error) {
console.error('Error applying awareness update:', error);
}
}
});
// Handle connection events
this.socket.on('connect', this.onConnect);
this.socket.on('disconnect', this.onDisconnect);
// Listen for document updates from Yjs
this.doc.on('update', async (update, origin) => {
if (origin !== 'server' && this.isConnected) {
await tick(); // Ensure the DOM is updated before sending
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Array.from(update),
data: {
content: {
md: mdValue,
html: htmlValue,
json: jsonValue
}
}
});
}
});
// Listen for awareness updates from Yjs
if (awareness) {
awareness.on('change', ({ added, updated, removed }, origin) => {
if (origin !== 'server' && this.isConnected) {
const changedClients = added.concat(updated).concat(removed);
const awarenessUpdate = awareness.encodeUpdate(changedClients);
this.socket.emit('ydoc:awareness:update', {
document_id: this.documentId,
user_id: this.socket.id,
update: Array.from(awarenessUpdate)
});
}
});
}
if (this.socket.connected) {
this.isConnected = true;
this.joinDocument();
}
}
onConnect = () => {
this.isConnected = true;
this.joinDocument();
};
onDisconnect = () => {
this.isConnected = false;
this.synced = false;
};
destroy() {
this.socket.off('ydoc:document:update');
this.socket.off('ydoc:document:state');
this.socket.off('ydoc:awareness:update');
this.socket.off('connect', this.onConnect);
this.socket.off('disconnect', this.onDisconnect);
if (this.isConnected) {
this.socket.emit('ydoc:document:leave', {
document_id: this.documentId,
user_id: this.user?.id
});
}
}
}
let provider = null;
// Simple awareness implementation
class SimpleAwareness {
constructor(yDoc) {
// Yjs awareness expects clientID (not clientId) property
this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff);
// Map from clientID (number) to state (object)
this._states = new Map(); // _states, not states; will make getStates() for compat
this._updateHandlers = [];
this._localState = {};
// As in Yjs Awareness, add our local state to the states map from the start:
this._states.set(this.clientID, this._localState);
}
on(event, handler) {
if (event === 'change') this._updateHandlers.push(handler);
}
off(event, handler) {
if (event === 'change') {
const i = this._updateHandlers.indexOf(handler);
if (i !== -1) this._updateHandlers.splice(i, 1);
}
}
getLocalState() {
return this._states.get(this.clientID) || null;
}
getStates() {
// Yjs returns a Map (clientID->state)
return this._states;
}
setLocalStateField(field, value) {
let localState = this._states.get(this.clientID);
if (!localState) {
localState = {};
this._states.set(this.clientID, localState);
}
localState[field] = value;
// After updating, fire 'update' event to all handlers
for (const cb of this._updateHandlers) {
// Follows Yjs Awareness ({ added, updated, removed }, origin)
cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
}
}
applyUpdate(update, origin) {
// Very simple: Accepts a serialized JSON state for now as Uint8Array
try {
const str = new TextDecoder().decode(update);
const obj = JSON.parse(str);
// Should be a plain object: { clientID: state, ... }
for (const [k, v] of Object.entries(obj)) {
this._states.set(+k, v);
}
for (const cb of this._updateHandlers) {
cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
}
} catch (e) {
console.warn('SimpleAwareness: Could not decode update:', e);
}
}
encodeUpdate(clients) {
// Encodes the states for the given clientIDs as Uint8Array (JSON)
const obj = {};
for (const id of clients || Array.from(this._states.keys())) {
const st = this._states.get(id);
if (st) obj[id] = st;
}
const json = JSON.stringify(obj);
return new TextEncoder().encode(json);
}
}
// Yjs collaboration extension
const YjsCollaboration = Extension.create({
name: 'yjsCollaboration',
addProseMirrorPlugins() {
if (!collaboration || !yXmlFragment) return [];
const plugins = [
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo
})
];
if (awareness) {
plugins.push(yCursorPlugin(awareness));
}
return plugins;
}
});
function initializeCollaboration() {
if (!collaboration) return;
// Create Yjs document
ydoc = new Y.Doc();
yXmlFragment = ydoc.getXmlFragment('prosemirror');
awareness = new SimpleAwareness(ydoc);
// Create custom Socket.IO provider
provider = new SocketIOProvider(ydoc, documentId, socket, user);
}
let floatingMenuElement = null;
let bubbleMenuElement = null;
let element;
let floatingMenuElement: Element | null = null;
let bubbleMenuElement: Element | null = null;
let element: Element | null = null;
const options = {
throwOnError: false
@@ -821,9 +506,14 @@
export const focus = () => {
if (editor) {
editor.view.focus();
// Scroll to the current selection
editor.view.dispatch(editor.view.state.tr.scrollIntoView());
try {
editor.view?.focus();
// Scroll to the current selection
editor.view?.dispatch(editor.view.state.tr.scrollIntoView());
} catch (e) {
// sometimes focusing throws an error, ignore
console.warn('Error focusing editor', e);
}
}
};
@@ -928,6 +618,20 @@
}
});
import { listDragHandlePlugin } from './RichTextInput/listDragHandlePlugin.js';
const ListItemDragHandle = Extension.create({
name: 'listItemDragHandle',
addProseMirrorPlugins() {
return [
listDragHandlePlugin({
itemTypeNames: ['listItem', 'taskItem'],
getEditor: () => this.editor
})
];
}
});
onMount(async () => {
content = value;
@@ -970,8 +674,9 @@
console.log('content', content);
if (collaboration) {
initializeCollaboration();
if (collaboration && documentId && socket && user) {
const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration');
provider = new SocketIOCollaborationProvider(documentId, socket, user, content);
}
console.log(bubbleMenuElement, floatingMenuElement);
@@ -983,7 +688,8 @@
StarterKit.configure({
link: link
}),
Placeholder.configure({ placeholder: () => _placeholder }),
...(dragHandle ? [ListItemDragHandle] : []),
Placeholder.configure({ placeholder: () => _placeholder, showOnlyWhenEditable: false }),
SelectionDecoration,
...(richText
@@ -1064,7 +770,7 @@
})
]
: []),
...(collaboration ? [YjsCollaboration] : [])
...(collaboration && provider ? [provider.getEditorExtension()] : [])
],
content: collaboration ? undefined : content,
autofocus: messageInput ? true : false,
@@ -1337,6 +1043,8 @@
enablePasteRules: richText
});
provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue }));
if (messageInput) {
selectTemplate();
}
@@ -1406,13 +1114,18 @@
</script>
{#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} />
</div>
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0">
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} />
</div>
{/if}
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
<div
bind:this={element}
class="relative w-full min-w-full h-full min-h-fit {className} {!editable
? 'cursor-not-allowed'
: ''}"
/>
@@ -0,0 +1,337 @@
import * as Y from 'yjs';
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
prosemirrorJSONToYDoc
} from 'y-prosemirror';
import type { Socket } from 'socket.io-client';
import type { Awareness } from 'y-protocols/awareness';
import type { SessionUser } from '$lib/stores';
import { Editor, Extension } from '@tiptap/core';
import { keymap } from 'prosemirror-keymap';
import { tick } from 'svelte';
const USER_COLORS = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
];
const generateUserColor = () => {
return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)];
};
export type EditorContentGetter = () => {
md: string;
html: string;
json: string;
};
// Custom Yjs Socket.IO provider
export class SocketIOCollaborationProvider {
private readonly doc = new Y.Doc();
private readonly awareness = new SimpleAwareness(this.doc);
private isConnected = false;
private synced = false;
private editor: Editor | null = null;
private editorContentGetter: EditorContentGetter | null = null;
constructor(
private readonly documentId: string,
private readonly socket: Socket,
private readonly user: SessionUser,
private readonly initialContent: string | null = null
) {
this.setupEventListeners();
}
public getEditorExtension() {
return Extension.create({
name: 'yjsCollaboration',
addProseMirrorPlugins: () => {
const yXmlFragment = this.doc.getXmlFragment('prosemirror');
if (!yXmlFragment) return [];
const plugins = [
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo
})
];
plugins.push(yCursorPlugin(this.awareness as unknown as Awareness));
return plugins;
}
});
}
public setEditor(editor: Editor, editorContentGetter: EditorContentGetter) {
this.editor = editor;
this.editorContentGetter = editorContentGetter;
}
private joinDocument() {
const userColor = generateUserColor();
this.socket.emit('ydoc:document:join', {
document_id: this.documentId,
user_id: this.user?.id,
user_name: this.user?.name,
user_color: userColor
});
// Set user awareness info
if (this.user) {
this.awareness.setLocalStateField('user', {
name: `${this.user.name}`,
color: userColor,
id: this.socket.id
});
}
}
private setupEventListeners() {
// Listen for document updates from server
this.socket.on('ydoc:document:update', (data) => {
if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
try {
const update = new Uint8Array(data.update);
Y.applyUpdate(this.doc, update);
} catch (error) {
console.error('Error applying Yjs update:', error);
}
}
});
// Listen for document state from server
this.socket.on('ydoc:document:state', async (data) => {
if (data.document_id === this.documentId) {
try {
if (data.state) {
const state = new Uint8Array(data.state);
if (state.length === 2 && state[0] === 0 && state[1] === 0) {
// Empty state, check if we have content to initialize
// check if editor empty as well
// const editor = await getEditorInstance();
const isEmptyEditor = !this.editor?.getText().trim();
if (isEmptyEditor && this.editor) {
if (this.initialContent && (data?.sessions ?? ['']).length === 1) {
const editorYdoc = prosemirrorJSONToYDoc(this.editor.schema, this.initialContent);
if (editorYdoc) {
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
}
}
} else {
// If the editor already has content, we don't need to send an empty state
if (this.doc.getXmlFragment('prosemirror').length > 0) {
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Y.encodeStateAsUpdate(this.doc)
});
} else {
console.warn('Yjs document is empty, not sending state.');
}
}
} else {
Y.applyUpdate(this.doc, state, 'server');
}
}
this.synced = true;
} catch (error) {
console.error('Error applying Yjs state:', error);
this.synced = false;
this.socket.emit('ydoc:document:state', {
document_id: this.documentId
});
}
}
});
// Listen for awareness updates
this.socket.on('ydoc:awareness:update', (data) => {
if (data.document_id === this.documentId) {
try {
const awarenessUpdate = new Uint8Array(data.update);
this.awareness.applyUpdate(awarenessUpdate, 'server');
} catch (error) {
console.error('Error applying awareness update:', error);
}
}
});
// Handle connection events
this.socket.on('connect', this.onConnect);
this.socket.on('disconnect', this.onDisconnect);
// Listen for document updates from Yjs
this.doc.on('update', async (update, origin) => {
if (this.editor && origin !== 'server' && this.isConnected) {
await tick(); // Ensure the DOM is updated before sending
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Array.from(update),
data: {
content: this.editorContentGetter?.() ?? {
md: '',
html: '',
json: ''
}
}
});
}
});
// Listen for awareness updates from Yjs
this.awareness.on(
'change',
(
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: string
) => {
if (origin !== 'server' && this.isConnected) {
const changedClients = added.concat(updated).concat(removed);
const awarenessUpdate = this.awareness.encodeUpdate(changedClients);
this.socket.emit('ydoc:awareness:update', {
document_id: this.documentId,
user_id: this.socket.id,
update: Array.from(awarenessUpdate)
});
}
}
);
if (this.socket.connected) {
this.isConnected = true;
this.joinDocument();
}
}
private readonly onConnect = () => {
this.isConnected = true;
this.joinDocument();
};
private readonly onDisconnect = () => {
this.isConnected = false;
this.synced = false;
};
public destroy() {
this.socket.off('ydoc:document:update');
this.socket.off('ydoc:document:state');
this.socket.off('ydoc:awareness:update');
this.socket.off('connect', this.onConnect);
this.socket.off('disconnect', this.onDisconnect);
if (this.isConnected) {
this.socket.emit('ydoc:document:leave', {
document_id: this.documentId,
user_id: this.user?.id
});
}
this.editor = null;
this.editorContentGetter = null;
}
}
// Simple awareness implementation
class SimpleAwareness {
public readonly clientID: number;
private readonly _states: Map<number, any>;
private readonly _updateHandlers: any[];
private readonly _localState: any;
public constructor(public readonly doc: Y.Doc) {
// Yjs awareness expects clientID (not clientId) property
this.clientID = doc.clientID ? doc.clientID : Math.floor(Math.random() * 0xffffffff);
// Map from clientID (number) to state (object)
this._states = new Map(); // _states, not states; will make getStates() for compat
this._updateHandlers = [];
this._localState = {};
// As in Yjs Awareness, add our local state to the states map from the start:
this._states.set(this.clientID, this._localState);
}
public on(event: string, handler: any) {
if (event === 'change') this._updateHandlers.push(handler);
}
public off(event: string, handler: any) {
if (event === 'change') {
const i = this._updateHandlers.indexOf(handler);
if (i !== -1) this._updateHandlers.splice(i, 1);
}
}
public getLocalState() {
return this._states.get(this.clientID) || null;
}
public getStates() {
// Yjs returns a Map (clientID->state)
return this._states;
}
public setLocalStateField(field: string, value: any) {
let localState = this._states.get(this.clientID);
if (!localState) {
localState = {};
this._states.set(this.clientID, localState);
}
localState[field] = value;
// After updating, fire 'update' event to all handlers
for (const cb of this._updateHandlers) {
// Follows Yjs Awareness ({ added, updated, removed }, origin)
cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
}
}
public applyUpdate(update: Uint8Array, origin: string) {
// Very simple: Accepts a serialized JSON state for now as Uint8Array
try {
const str = new TextDecoder().decode(update);
const obj = JSON.parse(str);
// Should be a plain object: { clientID: state, ... }
for (const [k, v] of Object.entries(obj)) {
this._states.set(+k, v);
}
for (const cb of this._updateHandlers) {
cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
}
} catch (e) {
console.warn('SimpleAwareness: Could not decode update:', e);
}
}
public encodeUpdate(clients: number[]) {
// Encodes the states for the given clientIDs as Uint8Array (JSON)
const obj: Record<number, any> = {};
for (const id of clients || Array.from(this._states.keys())) {
const st = this._states.get(id);
if (st) obj[id] = st;
}
const json = JSON.stringify(obj);
return new TextEncoder().encode(json);
}
}
@@ -0,0 +1,513 @@
import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Fragment } from 'prosemirror-model';
export const listPointerDragKey = new PluginKey('listPointerDrag');
export function listDragHandlePlugin(options = {}) {
const {
itemTypeNames = ['listItem', 'taskItem', 'list_item'],
// Tiptap editor getter (required for indent/outdent)
getEditor = null,
// UI copy / classes
handleTitle = 'Drag to move',
handleInnerHTML = '⋮⋮',
classItemWithHandle = 'pm-li--with-handle',
classHandle = 'pm-list-drag-handle',
classDropBefore = 'pm-li-drop-before',
classDropAfter = 'pm-li-drop-after',
classDropInto = 'pm-li-drop-into',
classDropOutdent = 'pm-li-drop-outdent',
classDraggingGhost = 'pm-li-ghost',
// Behavior
dragThresholdPx = 2,
intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
outdentThresholdX = 10 // X ≤ this → “outdent”
} = options;
const itemTypesSet = new Set(itemTypeNames);
const isListItem = (node) => node && itemTypesSet.has(node.type.name);
const listTypeNames = new Set([
'bulletList',
'orderedList',
'taskList',
'bullet_list',
'ordered_list'
]);
const isListNode = (node) => node && listTypeNames.has(node.type.name);
function listTypeToItemTypeName(listNode) {
const name = listNode?.type?.name;
if (!name) return null;
// Prefer tiptap names first, then ProseMirror snake_case
if (name === 'taskList') {
return itemTypesSet.has('taskItem') ? 'taskItem' : null;
}
if (name === 'orderedList' || name === 'bulletList') {
return itemTypesSet.has('listItem')
? 'listItem'
: itemTypesSet.has('list_item')
? 'list_item'
: null;
}
if (name === 'ordered_list' || name === 'bullet_list') {
return itemTypesSet.has('list_item')
? 'list_item'
: itemTypesSet.has('listItem')
? 'listItem'
: null;
}
return null;
}
// Find the nearest enclosing list container at/around a pos
function getEnclosingListAt(doc, pos) {
const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1)));
for (let d = $pos.depth; d >= 0; d--) {
const n = $pos.node(d);
if (isListNode(n)) {
const start = $pos.before(d);
return { node: n, depth: d, start, end: start + n.nodeSize };
}
}
return null;
}
function normalizeItemForList(state, itemNode, targetListNodeOrType) {
const schema = state.schema;
const targetListNode = targetListNodeOrType;
const wantedItemTypeName =
typeof targetListNode === 'string'
? targetListNode // allow passing type name directly
: listTypeToItemTypeName(targetListNode);
if (!wantedItemTypeName) return itemNode;
const wantedType = schema.nodes[wantedItemTypeName];
if (!wantedType) return itemNode;
const wantedListType = schema.nodes[targetListNode.type.name];
if (!wantedListType) return itemNode;
// Deep‑normalize children recursively
const normalizeNode = (node, parentTargetListNode) => {
console.log(
'Normalizing node',
node.type.name,
'for parent list',
parentTargetListNode?.type?.name
);
if (isListNode(node)) {
// Normalize each list item inside
const normalizedItems = [];
node.content.forEach((li) => {
normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode));
});
return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks);
}
// Not a list node → but may contain lists deeper
if (node.content && node.content.size > 0) {
const nChildren = [];
node.content.forEach((ch) => {
nChildren.push(normalizeNode(ch, parentTargetListNode));
});
return node.type.create(node.attrs, Fragment.from(nChildren), node.marks);
}
// leaf
return node;
};
const normalizedContent = [];
itemNode.content.forEach((child) => {
normalizedContent.push(normalizeNode(child, targetListNode));
});
const newAttrs = {};
if (wantedType.attrs) {
for (const key in wantedType.attrs) {
if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) {
newAttrs[key] = itemNode.attrs[key];
} else {
const spec = wantedType.attrs[key];
newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null;
}
}
}
if (wantedItemTypeName !== itemNode.type.name) {
// If changing type, ensure no disallowed marks are kept
const allowed = wantedType.spec?.marks;
const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : [];
console.log(normalizedContent);
return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks);
}
try {
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
} catch {
// Fallback – wrap content if schema requires a block
const para = schema.nodes.paragraph;
if (para) {
const wrapped =
itemNode.content.firstChild?.type === para
? Fragment.from(normalizedContent)
: Fragment.from([para.create(null, normalizedContent)]);
return wantedType.create(newAttrs, wrapped, itemNode.marks);
}
}
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
}
// ---------- decorations ----------
function buildHandleDecos(doc) {
const decos = [];
doc.descendants((node, pos) => {
if (!isListItem(node)) return;
decos.push(Decoration.node(pos, pos + node.nodeSize, { class: classItemWithHandle }));
decos.push(
Decoration.widget(
pos + 1,
(view, getPos) => {
const el = document.createElement('span');
el.className = classHandle;
el.setAttribute('title', handleTitle);
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Drag list item');
el.contentEditable = 'false';
el.innerHTML = handleInnerHTML;
el.pmGetPos = getPos;
return el;
},
{ side: -1, ignoreSelection: true }
)
);
});
return DecorationSet.create(doc, decos);
}
function findListItemAround($pos) {
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (isListItem(node)) {
const start = $pos.before(d);
return { depth: d, node, start, end: start + node.nodeSize };
}
}
return null;
}
function infoFromCoords(view, clientX, clientY) {
const result = view.posAtCoords({ left: clientX, top: clientY });
if (!result) return null;
const $pos = view.state.doc.resolve(result.pos);
const li = findListItemAround($pos);
if (!li) return null;
const dom = /** @type {Element} */ (view.nodeDOM(li.start));
if (!(dom instanceof Element)) return null;
const rect = dom.getBoundingClientRect();
const isRTL = getComputedStyle(dom).direction === 'rtl';
const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left;
const yInTopHalf = clientY - rect.top < rect.height / 2;
const mode =
xFromLeft <= outdentThresholdX
? 'outdent'
: xFromLeft >= intoThresholdX
? 'into'
: yInTopHalf
? 'before'
: 'after';
return { ...li, dom, mode };
}
// ---------- state ----------
const init = (state) => ({
decorations: buildHandleDecos(state.doc),
dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
dropTarget: null // {start, end, mode, toPos}
});
const apply = (tr, prev) => {
let decorations = tr.docChanged
? buildHandleDecos(tr.doc)
: prev.decorations.map(tr.mapping, tr.doc);
let next = { ...prev, decorations };
const meta = tr.getMeta(listPointerDragKey);
if (meta) {
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
if (meta.type === 'set-drop') next = { ...next, dropTarget: meta.drop };
if (meta.type === 'clear') next = { ...next, dragging: null, dropTarget: null };
}
return next;
};
const decorationsProp = (state) => {
const ps = listPointerDragKey.getState(state);
if (!ps) return null;
let deco = ps.decorations;
if (ps.dropTarget) {
const { start, end, mode } = ps.dropTarget;
const cls =
mode === 'before'
? classDropBefore
: mode === 'after'
? classDropAfter
: mode === 'into'
? classDropInto
: classDropOutdent;
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
}
return deco;
};
// ---------- helpers ----------
const setDrag = (view, dragging) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
const setDrop = (view, drop) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
const clearAll = (view) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
function moveItem(view, fromStart, toPos) {
const { state, dispatch } = view;
const { doc } = state;
const orig = doc.nodeAt(fromStart);
if (!orig || !isListItem(orig)) return { ok: false };
// no-op if dropping into own range
if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
return { ok: true, newStart: fromStart };
// find item depth
const $inside = doc.resolve(fromStart + 1);
let itemDepth = -1;
for (let d = $inside.depth; d > 0; d--) {
if ($inside.node(d) === orig) {
itemDepth = d;
break;
}
}
if (itemDepth < 0) return { ok: false };
const listDepth = itemDepth - 1;
const parentList = $inside.node(listDepth);
const parentListStart = $inside.before(listDepth);
// delete item (or entire list if only child)
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
const deleteTo =
parentList.childCount === 1
? parentListStart + parentList.nodeSize
: fromStart + orig.nodeSize;
let tr = state.tr.delete(deleteFrom, deleteTo);
// Compute mapped drop point with right bias so "after" stays after
const mappedTo = tr.mapping.map(toPos, 1);
// Detect enclosing list at destination, then normalize the item type
const listAtDest = getEnclosingListAt(tr.doc, mappedTo);
const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig;
try {
tr = tr.insert(mappedTo, nodeToInsert);
} catch (e) {
console.log('Direct insert failed, trying to wrap in list', e);
// If direct insert fails (e.g., not inside a list), try wrapping in a list
const schema = state.schema;
const wrapName =
parentList.type.name === 'taskList'
? schema.nodes.taskList
? 'taskList'
: null
: parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list'
? schema.nodes.orderedList
? 'orderedList'
: schema.nodes.ordered_list
? 'ordered_list'
: null
: schema.nodes.bulletList
? 'bulletList'
: schema.nodes.bullet_list
? 'bullet_list'
: null;
if (wrapName) {
const wrapType = schema.nodes[wrapName];
if (wrapType) {
const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType));
tr = tr.insert(mappedTo, frag);
} else {
return { ok: false };
}
} else {
return { ok: false };
}
}
dispatch(tr.scrollIntoView());
return { ok: true, newStart: mappedTo };
}
function ensureGhost(view, fromStart) {
const el = document.createElement('div');
el.className = classDraggingGhost;
const dom = /** @type {Element} */ (view.nodeDOM(fromStart));
const rect = dom instanceof Element ? dom.getBoundingClientRect() : null;
if (rect) {
el.style.position = 'fixed';
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.width = rect.width + 'px';
el.style.pointerEvents = 'none';
el.style.opacity = '0.75';
el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
}
document.body.appendChild(el);
return el;
}
const updateGhost = (ghost, dx, dy) => {
if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
};
// ---------- plugin ----------
return new Plugin({
key: listPointerDragKey,
state: { init: (_, state) => init(state), apply },
props: {
decorations: decorationsProp,
handleDOMEvents: {
mousedown(view, event) {
const t = /** @type {HTMLElement} */ (event.target);
const handle = t.closest?.(`.${classHandle}`);
if (!handle) return false;
event.preventDefault();
const getPos = handle.pmGetPos;
if (typeof getPos !== 'function') return true;
const posInside = getPos();
const fromStart = posInside - 1;
try {
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
);
} catch {}
const startMouse = { x: event.clientX, y: event.clientY };
const ghostEl = ensureGhost(view, fromStart);
setDrag(view, { fromStart, startMouse, ghostEl, active: false });
const onMove = (e) => {
const ps = listPointerDragKey.getState(view.state);
if (!ps?.dragging) return;
const dx = e.clientX - ps.dragging.startMouse.x;
const dy = e.clientY - ps.dragging.startMouse.y;
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
setDrag(view, { ...ps.dragging, active: true });
}
updateGhost(ps.dragging.ghostEl, dx, dy);
const info = infoFromCoords(view, e.clientX, e.clientY);
if (!info) return setDrop(view, null);
// for before/after: obvious
// for into/outdent: we still insert AFTER target and then run sink/lift
const toPos =
info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target
const prev = listPointerDragKey.getState(view.state)?.dropTarget;
if (
!prev ||
prev.start !== info.start ||
prev.end !== info.end ||
prev.mode !== info.mode
) {
setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos });
}
};
const endDrag = () => {
window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mouseup', endDrag, true);
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
// Helper: figure out the list item node type name at/around a pos
const getListItemTypeNameAt = (doc, pos) => {
const direct = doc.nodeAt(pos);
if (direct && isListItem(direct)) return direct.type.name;
const $pos = doc.resolve(Math.min(pos + 1, doc.content.size));
for (let d = $pos.depth; d > 0; d--) {
const n = $pos.node(d);
if (isListItem(n)) return n.type.name;
}
const prefs = ['taskItem', 'listItem', 'list_item'];
for (const p of prefs) if (itemTypesSet.has(p)) return p;
return Array.from(itemTypesSet)[0];
};
if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
const { fromStart } = ps.dragging;
const { toPos, mode } = ps.dropTarget;
const res = moveItem(view, fromStart, toPos);
if (res.ok && typeof res.newStart === 'number' && getEditor) {
const editor = getEditor();
if (editor?.commands) {
// Select the moved node so sink/lift applies to it
editor.commands.setNodeSelection(res.newStart);
const typeName = getListItemTypeNameAt(view.state.doc, res.newStart);
const chain = editor.chain().focus();
if (mode === 'into') {
if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run();
else chain.run();
} else {
chain.run(); // finalize focus/selection
}
}
}
}
clearAll(view);
};
window.addEventListener('mousemove', onMove, true);
window.addEventListener('mouseup', endDrag, true);
return true;
},
keydown(view, event) {
if (event.key === 'Escape') {
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
clearAll(view);
return true;
}
return false;
}
}
}
});
}
+39 -1
View File
@@ -49,7 +49,45 @@
<div bind:this={sceneParentElement} class="relative {className}">
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
{@html svg}
{@html DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
WHOLE_DOCUMENT: false,
ADD_TAGS: ['style', 'foreignObject'], // include foreignObject if using HTML labels
ADD_ATTR: [
'class',
'style',
'id',
'data-*',
'viewBox',
'preserveAspectRatio',
// markers / arrows
'markerWidth',
'markerHeight',
'markerUnits',
'refX',
'refY',
'orient',
// hrefs (for gradients, markers, etc.)
'href',
'xlink:href',
// text positioning
'dominant-baseline',
'text-anchor',
// pattern / clip / mask units
'clipPathUnits',
'filterUnits',
'patternUnits',
'patternContentUnits',
'maskUnits',
// a11y niceties
'role',
'aria-label',
'aria-labelledby',
'aria-hidden',
'tabindex'
],
SANITIZE_DOM: true
})}
</div>
{#if content}
@@ -63,7 +63,7 @@
{/if}
<button
class=" cursor-pointer self-center p-0.5 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed"
class=" cursor-pointer self-center p-0.5 flex h-fit items-center rounded-full transition border dark:border-gray-600 border-dashed"
type="button"
aria-label={$i18n.t('Add Tag')}
on:click={() => {
@@ -76,7 +76,7 @@
viewBox="0 0 16 16"
aria-hidden="true"
fill="currentColor"
class="w-3 h-3 {showTagInput ? 'rotate-45' : ''} transition-all transform"
class="size-2.5 {showTagInput ? 'rotate-45' : ''} transition-all transform"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
@@ -0,0 +1,33 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let tag;
export let onDelete = () => {};
</script>
{#if tag}
<Tooltip content={tag.name}>
<button
aria-label={$i18n.t('Remove this tag from list')}
class="relative group/tags px-1.5 py-[0.5px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-lg bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
on:click={() => {
onDelete();
}}
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="hidden group-hover/tags:block transition">
<div class="rounded-full pl-[1px] backdrop-blur-sm h-full flex self-center cursor-pointer">
<XMark className="size-3" strokeWidth="2.5" />
</div>
</div>
</button>
</Tooltip>
{/if}
+8 -24
View File
@@ -2,34 +2,18 @@
import { createEventDispatcher } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '../Badge.svelte';
import TagItem from './TagItem.svelte';
const dispatch = createEventDispatcher();
export let tags = [];
</script>
{#each tags as tag}
<Tooltip content={tag.name}>
<li
class="relative group/tags px-1.5 py-[0.2px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-full bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="absolute invisible right-0.5 group-hover/tags:visible transition">
<button
class="rounded-full border bg-white dark:bg-gray-700 h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
aria-label={$i18n.t('Remove this tag from list')}
>
<XMark className="size-3" strokeWidth="2.5" />
</button>
</div>
</li>
</Tooltip>
<TagItem
{tag}
onDelete={() => {
dispatch('delete', tag.name);
}}
/>
{/each}
+13 -4
View File
@@ -27,10 +27,19 @@
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
valves[property] =
(valves[property] ?? null) === null
? (valvesSpec.properties[property]?.default ?? '')
: null;
const propertySpec = valvesSpec.properties[property] ?? {};
if ((valves[property] ?? null) === null) {
// Initialize to custom value
if ((propertySpec?.type ?? null) === 'array') {
const defaultArray = propertySpec?.default ?? [];
valves[property] = Array.isArray(defaultArray) ? defaultArray.join(', ') : '';
} else {
valves[property] = propertySpec?.default ?? '';
}
} else {
valves[property] = null;
}
dispatch('change');
}}
@@ -39,6 +39,10 @@
}
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
@@ -1,4 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { getAdminDetails } from '$lib/apis/auths';
import { onMount, tick, getContext } from 'svelte';
import { config } from '$lib/stores';
@@ -38,7 +41,11 @@
style="white-space: pre-wrap;"
>
{#if ($config?.ui?.pending_user_overlay_content ?? '').trim() !== ''}
{$config.ui.pending_user_overlay_content}
{@html marked.parse(
DOMPurify.sanitize(
($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>')
)
)}
{:else}
{$i18n.t('Your account status is currently pending activation.')}{'\n'}{$i18n.t(
'To access the WebUI, please reach out to the administrator. Admins can manage user statuses from the Admin Panel.'
+90 -18
View File
@@ -12,14 +12,28 @@
import calendar from 'dayjs/plugin/calendar';
import Loader from '../common/Loader.svelte';
import { createMessagesList } from '$lib/utils';
import { user } from '$lib/stores';
import { config, user } from '$lib/stores';
import Messages from '../chat/Messages.svelte';
import { goto } from '$app/navigation';
import PencilSquare from '../icons/PencilSquare.svelte';
import PageEdit from '../icons/PageEdit.svelte';
dayjs.extend(calendar);
export let show = false;
export let onClose = () => {};
let actions = [
{
label: 'Start a new conversation',
onClick: async () => {
await goto(`/${query ? `?q=${query}` : ''}`);
show = false;
onClose();
},
icon: PencilSquare
}
];
let query = '';
let page = 1;
@@ -55,7 +69,13 @@
return;
}
const chatId = chatList[selectedIdx].id;
const selectedChatIdx = selectedIdx - actions.length;
if (selectedChatIdx < 0) {
selectedChat = null;
return;
}
const chatId = chatList[selectedChatIdx].id;
const chat = await getChatById(localStorage.token, chatId).catch(async (error) => {
return null;
@@ -91,6 +111,10 @@
};
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
@@ -145,9 +169,9 @@
chatListLoading = false;
};
const init = () => {
$: if (show) {
searchHandler();
};
}
const onKeyDown = (e) => {
const searchOptions = document.getElementById('search-options-container');
@@ -158,13 +182,13 @@
if (e.code === 'Escape') {
show = false;
onClose();
} else if (e.code === 'Enter' && (chatList ?? []).length > 0) {
} else if (e.code === 'Enter') {
const item = document.querySelector(`[data-arrow-selected="true"]`);
if (item) {
item?.click();
show = false;
}
show = false;
return;
} else if (e.code === 'ArrowDown') {
const searchInput = document.getElementById('search-input');
@@ -178,7 +202,7 @@
}
}
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1);
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
} else if (e.code === 'ArrowUp') {
if (selectedIdx === 0) {
const searchInput = document.getElementById('search-input');
@@ -201,7 +225,23 @@
};
onMount(() => {
init();
actions = [
...actions,
...(($config?.features?.enable_notes ?? false) &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
? [
{
label: 'Create a new note',
onClick: async () => {
await goto(`/notes${query ? `?content=${query}` : ''}`);
show = false;
onClose();
},
icon: PageEdit
}
]
: [])
];
document.addEventListener('keydown', onKeyDown);
});
@@ -215,8 +255,8 @@
</script>
<Modal size="xl" bind:show>
<div class="py-2.5 dark:text-gray-300 text-gray-700">
<div class="px-3.5 pb-1.5">
<div class="py-3 dark:text-gray-300 text-gray-700">
<div class="px-4 pb-1.5">
<SearchInput
bind:value={query}
on:input={searchHandler}
@@ -238,7 +278,7 @@
show = false;
return;
} else if (e.code === 'ArrowDown') {
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1);
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
} else if (e.code === 'ArrowUp') {
selectedIdx = Math.max(selectedIdx - 1, 0);
} else {
@@ -253,13 +293,45 @@
<!-- <hr class="border-gray-50 dark:border-gray-850 my-1" /> -->
<div class="flex px-3 pb-1">
<div class="flex px-4 pb-1">
<div
class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1"
class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1 pr-2"
>
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2 px-2">
{$i18n.t('Actions')}
</div>
{#each actions as action, idx (action.label)}
<button
class=" w-full flex items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
dragabble="false"
on:mouseenter={() => {
selectedIdx = idx;
}}
on:click={async () => {
await action.onClick();
}}
>
<div class="pr-2">
<svelte:component this={action.icon} />
</div>
<div class=" flex-1 text-left">
<div class="text-ellipsis line-clamp-1 w-full">
{$i18n.t(action.label)}
</div>
</div>
</button>
{/each}
{#if chatList}
<hr class="border-gray-50 dark:border-gray-850 my-3" />
{#if chatList.length === 0}
<div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5">
<div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 py-4">
{$i18n.t('No results found')}
</div>
{/if}
@@ -294,15 +366,15 @@
{/if}
<a
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx
class=" w-full flex justify-between items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx + actions.length
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
href="/c/{chat.id}"
draggable="false"
data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
data-arrow-selected={selectedIdx === idx + actions.length ? 'true' : undefined}
on:mouseenter={() => {
selectedIdx = idx;
selectedIdx = idx + actions.length;
}}
on:click={async () => {
await goto(`/c/${chat.id}`);
+106 -94
View File
@@ -65,6 +65,8 @@
const BREAKPOINT = 768;
let scrollTop = 0;
let navElement;
let shiftKey = false;
@@ -528,7 +530,7 @@
placement="right"
>
<button
class="flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group {isWindows
class="flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group {isWindows
? 'cursor-pointer'
: 'cursor-[e-resize]'}"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
@@ -551,7 +553,7 @@
<div class="">
<Tooltip content={$i18n.t('New Chat')} placement="right">
<a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/"
draggable="false"
on:click={async (e) => {
@@ -573,7 +575,7 @@
<div class="">
<Tooltip content={$i18n.t('Search')} placement="right">
<button
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
on:click={(e) => {
e.stopImmediatePropagation();
e.preventDefault();
@@ -594,7 +596,7 @@
<div class="">
<Tooltip content={$i18n.t('Notes')} placement="right">
<a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/notes"
on:click={async (e) => {
e.stopImmediatePropagation();
@@ -618,7 +620,7 @@
<div class="">
<Tooltip content={$i18n.t('Workspace')} placement="right">
<a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/workspace"
on:click={async (e) => {
e.stopImmediatePropagation();
@@ -666,7 +668,7 @@
}}
>
<div
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
>
<div class=" self-center flex items-center justify-center size-9">
<img
@@ -704,10 +706,10 @@
: 'invisible'}"
>
<div
class="sidebar px-1.5 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 bg-gray-50 dark:bg-gray-950"
class="sidebar px-2 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-3"
>
<a
class="flex items-center rounded-lg p-1.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-850 transition no-drag-region"
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition no-drag-region"
href="/"
draggable="false"
on:click={newChatHandler}
@@ -730,7 +732,7 @@
placement="bottom"
>
<button
class="flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition {isWindows
class="flex rounded-xl size-8.5 justify-center items-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition {isWindows
? 'cursor-pointer'
: 'cursor-[w-resize]'}"
on:click={() => {
@@ -743,102 +745,120 @@
</div>
</button>
</Tooltip>
<div
class="{scrollTop > 0
? 'visible'
: 'invisible'} sidebar-bg-gradient-to-b bg-linear-to-b from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mb-6"
></div>
</div>
<div class="pb-1.5">
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-new-chat-button"
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
on:click={newChatHandler}
aria-label={$i18n.t('New Chat')}
>
<div class="self-center">
<PencilSquare className=" size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
</div>
</a>
</div>
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<button
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
aria-label={$i18n.t('Search')}
>
<div class="self-center">
<Search strokeWidth="2" className="size-4.5" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
</div>
</button>
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div
class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-3 pb-3"
on:scroll={(e) => {
if (e.target.scrollTop === 0) {
scrollTop = 0;
} else {
scrollTop = e.target.scrollTop;
}
}}
>
<div class="pb-1.5">
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/notes"
on:click={itemClickHandler}
id="sidebar-new-chat-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
aria-label={$i18n.t('Notes')}
on:click={newChatHandler}
aria-label={$i18n.t('New Chat')}
>
<div class="self-center">
<Note className="size-4.5" strokeWidth="2" />
<PencilSquare className=" size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
<div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
</div>
</a>
</div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={itemClickHandler}
<button
id="sidebar-search-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
aria-label={$i18n.t('Workspace')}
aria-label={$i18n.t('Search')}
>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
<Search strokeWidth="2" className="size-4.5" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
</div>
</a>
</button>
</div>
{/if}
</div>
<div class="relative flex flex-col flex-1">
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-notes-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/notes"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Notes')}
>
<div class="self-center">
<Note className="size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
</div>
</a>
</div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-workspace-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Workspace')}
>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
</div>
</a>
</div>
{/if}
</div>
{#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0}
<PinnedModelList bind:selectedChatId {shiftKey} />
{/if}
@@ -1157,7 +1177,10 @@
</Folder>
</div>
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 bg-gray-50 dark:bg-gray-950 sidebar">
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-3 sidebar">
<div
class=" sidebar-bg-gradient-to-t bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
></div>
<div class="flex flex-col font-primary">
{#if $user !== undefined && $user !== null}
<UserMenu
@@ -1169,7 +1192,7 @@
}}
>
<div
class=" flex items-center rounded-xl py-2 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class=" flex items-center rounded-2xl py-2 px-1.5 w-full hover:bg-gray-100/50 dark:hover:bg-gray-900/50 transition"
>
<div class=" self-center mr-3">
<img
@@ -1188,14 +1211,3 @@
</div>
</div>
{/if}
<style>
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>
@@ -44,10 +44,11 @@
/>
<div
id="sidebar-channel-item"
bind:this={itemElement}
class=" w-full {className} rounded-lg flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page
class=" w-full {className} rounded-xl flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page
.url.pathname === `/channels/${channel.id}`
? 'bg-gray-100 dark:bg-gray-900'
? 'bg-gray-100 dark:bg-gray-900 selected'
: ''} px-2.5 py-1"
>
<a
@@ -44,8 +44,12 @@
accessControl = channel.access_control;
};
$: if (channel) {
init();
$: if (show) {
if (channel) {
init();
}
} else {
resetHandler();
}
let showDeleteConfirmDialog = false;
@@ -68,6 +72,12 @@
show = false;
};
const resetHandler = () => {
name = '';
accessControl = {};
loading = false;
};
</script>
<Modal size="sm" bind:show>
@@ -115,8 +125,8 @@
<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 class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl accessRoles={['read', 'write']} />
</div>
</div>
@@ -336,17 +336,19 @@
{/if}
<div
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={draggable && !confirmEdit}
>
{#if confirmEdit}
<div
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-100 dark:bg-gray-900'
? 'bg-gray-100 dark:bg-gray-900 selected'
: selected
? 'bg-gray-100 dark:bg-gray-950'
? 'bg-gray-100 dark:bg-gray-950 selected'
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
? 'cursor-not-allowed'
: ''}"
@@ -394,11 +396,12 @@
</div>
{:else}
<a
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-100 dark:bg-gray-900'
? 'bg-gray-100 dark:bg-gray-900 selected'
: selected
? 'bg-gray-100 dark:bg-gray-950'
? 'bg-gray-100 dark:bg-gray-950 selected'
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
href="/c/{id}"
on:click={() => {
@@ -438,11 +441,12 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
id="sidebar-chat-item-menu"
class="
{id === $chatId || confirmEdit
? 'from-gray-100 dark:from-gray-900'
? 'from-gray-100 dark:from-gray-900 selected'
: selected
? 'from-gray-100 dark:from-gray-950'
? 'from-gray-100 dark:from-gray-950 selected'
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute {className === 'pr-2'
? 'right-[8px]'
@@ -332,15 +332,13 @@
let isExpandedUpdateTimeout;
const isExpandedUpdateDebounceHandler = (open) => {
const isExpandedUpdateDebounceHandler = () => {
clearTimeout(isExpandedUpdateTimeout);
isExpandedUpdateTimeout = setTimeout(() => {
isExpandedUpdateHandler();
}, 500);
};
$: isExpandedUpdateDebounceHandler(open);
const renameHandler = async () => {
console.log('Edit');
await tick();
@@ -431,9 +429,9 @@
<div class="w-full group">
<button
id="folder-{folderId}-button"
class="relative w-full py-1 px-1.5 rounded-lg flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
folderId
? 'bg-gray-100 dark:bg-gray-900'
? 'bg-gray-100 dark:bg-gray-900 selected'
: ''}"
on:dblclick={(e) => {
if (clickTimer) {
@@ -469,6 +467,7 @@
on:click={(e) => {
e.stopPropagation();
open = !open;
isExpandedUpdateDebounceHandler();
}}
>
{#if folders[folderId]?.meta?.icon}
@@ -130,7 +130,14 @@
name: 'false',
type: 'pinned'
}
];
].filter((item) => {
const pinnedValue = lastWord.slice(7);
if (pinnedValue) {
return item.id.startsWith(pinnedValue) && item.id !== pinnedValue;
} else {
return true;
}
});
} else if (lastWord.startsWith('shared:')) {
filteredItems = [
{
@@ -143,7 +150,14 @@
name: 'false',
type: 'shared'
}
];
].filter((item) => {
const sharedValue = lastWord.slice(7);
if (sharedValue) {
return item.id.startsWith(sharedValue) && item.id !== sharedValue;
} else {
return true;
}
});
} else if (lastWord.startsWith('archived:')) {
filteredItems = [
{
@@ -156,7 +170,14 @@
name: 'false',
type: 'archived'
}
];
].filter((item) => {
const archivedValue = lastWord.slice(9);
if (archivedValue) {
return item.id.startsWith(archivedValue) && item.id !== archivedValue;
} else {
return true;
}
});
} else {
filteredItems = [];
}
@@ -192,11 +213,14 @@
on:input={() => {
dispatch('input');
}}
on:focus={() => {
onFocus();
hovering = false;
focused = true;
initTags();
on:click={() => {
if (!focused) {
onFocus();
hovering = false;
focused = true;
initTags();
}
}}
on:blur={() => {
if (!hovering) {
@@ -239,6 +263,14 @@
}
} else {
// if the user types something, reset to the top selection.
if (!focused) {
onFocus();
hovering = false;
focused = true;
initTags();
}
selectedIdx = 0;
}
@@ -266,7 +298,7 @@
{#if focused && (filteredOptions.length > 0 || filteredItems.length > 0)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg"
class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-2xl z-10 shadow-lg"
id="search-options-container"
in:fade={{ duration: 50 }}
on:mouseenter={() => {
@@ -278,7 +310,7 @@
selectedIdx = 0;
}}
>
<div class="px-2 py-2 text-xs group">
<div class="px-3 py-2.5 text-xs group">
{#if filteredItems.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1 capitalize">
{selectedOption}

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