Compare commits

...
434 Commits
Author SHA1 Message Date
Tim Jaeryang Baek ba0088f39b Merge pull request #14441 from open-webui/dev
0.6.12
2025-05-29 03:59:24 +04:00
Timothy Jaeryang Baek 661625f362 doc: changelog 2025-05-29 03:56:42 +04:00
Timothy Jaeryang Baek 30b7ab3591 refac 2025-05-29 03:48:07 +04:00
Timothy Jaeryang Baek 7dc7d5c028 refac: PLEASE FOLLOW EXISTING CONVENTION 2025-05-29 03:47:02 +04:00
Timothy Jaeryang Baek 2c31f5c725 doc: readme 2025-05-29 03:41:38 +04:00
Timothy Jaeryang Baek a6864db8ec chore: format 2025-05-29 03:37:58 +04:00
Timothy Jaeryang Baek 21f85e63bf refac: styling 2025-05-29 03:37:13 +04:00
Timothy Jaeryang Baek 9220afe7b3 feat: custom advanced params 2025-05-29 03:33:11 +04:00
Timothy Jaeryang Baek bb4115fa0e refac: allow all params 2025-05-29 02:56:37 +04:00
Timothy Jaeryang Baek 551597b9cc chore: format 2025-05-29 02:36:33 +04:00
Timothy Jaeryang Baek cb4299eb98 refac 2025-05-29 02:33:40 +04:00
Tim Jaeryang Baek 042c37ea34 Merge pull request #14311 from Hisma/marker-api-content-extraction
feat: Marker api content extraction support
2025-05-29 02:21:13 +04:00
Timothy Jaeryang Baek 85a384fab5 enh: load tool by url 2025-05-29 02:08:54 +04:00
Timothy Jaeryang Baek 4461122a0e fix: /api/v1/retrieval/query/collection endpoint 2025-05-28 18:45:47 +04:00
Tim Jaeryang Baek c9f983f644 Merge pull request #14445 from NotYuSheng/chore/duplicate-css-elements
chore: removed duplicate css elements
2025-05-28 17:58:59 +04:00
Timothy Jaeryang Baek cc6cbf53e8 refac 2025-05-28 17:36:05 +04:00
Timothy Jaeryang Baek 4279762ea4 refac 2025-05-28 16:36:23 +04:00
notyusheng efedb7ab1f chore: removed duplicate css elements 2025-05-28 08:31:11 -04:00
Timothy Jaeryang Baek 32135a29bb fix: image preview/download 2025-05-28 15:57:40 +04:00
Timothy Jaeryang Baek 8a74bdce37 fix: message input issue 2025-05-28 15:36:04 +04:00
Timothy Jaeryang Baek 3d9a430927 refac: styling 2025-05-28 14:28:39 +04:00
Timothy Jaeryang Baek a27a095884 refac 2025-05-28 14:27:34 +04:00
Tim Jaeryang Baek 596298fa46 Merge pull request #14405 from SadmL/patch-2
[i18n] Russian locale update
2025-05-28 14:23:33 +04:00
Timothy Jaeryang Baek bf7a18a0f8 refac 2025-05-28 02:10:54 +04:00
Timothy Jaeryang Baek d81886e315 refac 2025-05-28 01:42:42 +04:00
Timothy Jaeryang Baek 7effb04782 refac 2025-05-28 01:41:49 +04:00
Timothy Jaeryang Baek f5fefb49d5 refac 2025-05-28 01:38:24 +04:00
Timothy Jaeryang Baek e4a53e0a3c refac 2025-05-28 01:34:53 +04:00
Link [Связной] e861ba5520 [i18n] Russian locale update 2025-05-27 16:28:09 +03:00
Tim Jaeryang Baek 100a764293 Merge pull request #14402 from torisetxd/parallelized-model-fetching
perf: Parallelize base model fetching
2025-05-27 16:56:44 +04:00
Timothy Jaeryang Baek 1d216b82ba refac 2025-05-27 16:48:17 +04:00
toriset 9eccce2444 Added proper type hints to new functions
Forgot about that...
2025-05-27 15:44:20 +03:00
toriset 27de981246 Parallelize base model fetching 2025-05-27 15:35:16 +03:00
Timothy Jaeryang Baek 40bea00e3d refac 2025-05-27 16:06:00 +04:00
Timothy Jaeryang Baek 8609471eeb Revert "Merge pull request #14379 from aleixdorca/dev"
This reverts commit cb135c370a, reversing
changes made to 8cf578d9fd.
2025-05-27 16:00:19 +04:00
Tim Jaeryang Baek cb135c370a Merge pull request #14379 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-05-27 15:59:37 +04:00
Tim Jaeryang Baek 8cf578d9fd Merge pull request #14368 from qingchunnh/Update_zh-CN-25527
i18n: Update & Improve zh-CN
2025-05-27 15:59:29 +04:00
Tim Jaeryang Baek 1aac41d6c3 Merge pull request #14400 from torisetxd/capabilities-fix
fix: Improve mobile styling for capabilities in workspace models
2025-05-27 15:59:16 +04:00
toriset 20403c9df7 Match capabilities styling to other settings in workspace models 2025-05-27 14:39:21 +03:00
Timothy Jaeryang Baek b944acd3ff refac: function cache 2025-05-27 14:39:35 +04:00
Tim Jaeryang Baek 1ec9e42c1e Merge pull request #14383 from TiancongLx/dev
i18n: complete and improve zh-TW
2025-05-27 13:56:51 +04:00
Tim Jaeryang Baek 5989e9bf75 Merge pull request #14387 from Zyfax/patch-1
fix: tts read aloud
2025-05-27 13:56:40 +04:00
Tim Jaeryang Baek 75b2e4a659 Merge pull request #14392 from hurxxxx/fix/improve-loading-functions
perf: Prevent duplicate function loads with caching helper and refactor
2025-05-27 13:56:13 +04:00
Gunwoo Hur 14c3d0c2d1 Prevent duplicate function module loads with caching helper and refactor 2025-05-27 18:08:58 +09:00
Tim Jaeryang Baek 737dc7797c Merge pull request #14390 from open-webui/dev
Dev
2025-05-27 12:59:28 +04:00
Timothy Jaeryang Baek eb14bfbcdf refac: bug report template 2025-05-27 12:59:01 +04:00
Zyfax 4995bfe571 fix: tts read aloud
Change: const content = removeAllDetails(content); to const content = removeAllDetails(message.content);

Error: Uncaught (in promise) ReferenceError: can't access lexical declaration 'z' before initialization.
ResponseMessage.svelte:215:42
2025-05-27 10:52:26 +02:00
Tiancong Li 098ec43413 i18n: update zh-TW 2025-05-27 16:16:09 +08:00
Aleix Dorca fcb03df9ce Update catalan translation.json 2025-05-27 09:02:42 +02:00
Hisma 19bb3589ee fix: add Datalab Marker API to Content Extraction Engine Dropdown 2025-05-27 02:24:53 -04:00
Hisma e12a79c0e2 fix: handle json output format correctly 2025-05-27 01:12:03 -04:00
Hisma a9405cc101 feat: Marker api content extraction support 2025-05-27 00:44:07 -04:00
qingchun 7988e0c6e2 i18n: Improve zh-CN 2025-05-27 11:25:12 +08:00
qingchun 14bf579e9c i18n: Fix zh-CN 2025-05-27 10:25:02 +08:00
qingchun fc2202ad76 i18n: Update zh-CN 2025-05-27 09:19:35 +08:00
Timothy Jaeryang Baek a2afd6f645 refac 2025-05-27 03:06:01 +04:00
Tim Jaeryang Baek b8e16211b9 Merge pull request #14364 from open-webui/dev
fix: image generation
2025-05-27 02:48:45 +04:00
Timothy Jaeryang Baek efb54aa2e4 fix: image generation 2025-05-27 02:48:22 +04:00
Tim Jaeryang Baek 9faa4c6a4c Merge pull request #14194 from open-webui/dev
0.6.11
2025-05-27 02:27:09 +04:00
Timothy Jaeryang Baek 5c74e56bd0 chore: format 2025-05-27 02:18:43 +04:00
Timothy Jaeryang Baek 8609fe7ab4 i18n: french 2025-05-27 02:18:02 +04:00
Tim Jaeryang Baek 1cb8fa0f03 Merge pull request #14362 from PVBLIC-F/fix/chat-engagement-critical
Fix/chat engagement critical
2025-05-27 02:17:34 +04:00
Timothy Jaeryang Baek a681d5c60e doc: changelog 2025-05-27 02:16:43 +04:00
cheadings71 256034e285 Update misc.py
Before fix: Chat engagement failed with TypeError and KeyError
After fix: Chat works smoothly with automatic title generation and proper history
2025-05-26 14:55:48 -07:00
cheadings71 d414662d23 fix: resolve chat engagement TypeError - Fix get_message_list() to return [] instead of None - Fix middleware to use correct metadata message_id - Add safe fallback for missing role field - Ensure assistant messages include role field 2025-05-26 14:35:09 -07:00
Timothy Jaeryang Baek 940a437631 refac 2025-05-27 01:16:11 +04:00
Timothy Jaeryang Baek aaff204e7b refac 2025-05-27 00:56:59 +04:00
Timothy Jaeryang Baek e68a3d28b5 refac 2025-05-27 00:40:55 +04:00
Timothy Jaeryang Baek 090619c27c refac 2025-05-27 00:29:16 +04:00
Timothy Jaeryang Baek 9c120affb6 chore: format 2025-05-27 00:21:23 +04:00
Timothy Jaeryang Baek 2c7ccc69fe enh: allow custom openapi json url 2025-05-27 00:20:47 +04:00
Timothy Jaeryang Baek a38e44e870 enh: external tool server custom name/description support 2025-05-27 00:10:33 +04:00
Timothy Jaeryang Baek 20f54616e7 chore: format 2025-05-26 23:55:39 +04:00
Timothy Jaeryang Baek b4caad928e feat: load function from url 2025-05-26 23:52:22 +04:00
Timothy Jaeryang Baek 7d756982c2 refac 2025-05-26 23:22:37 +04:00
Tim Jaeryang Baek 6062174602 Merge pull request #14228 from suleimanelkhoury/s3-tags-allowed-characters
fix: S3 allowed characters in Tags.
2025-05-26 22:43:21 +04:00
Timothy Jaeryang Baek 9a5febc2d1 feat: copy model link 2025-05-26 22:29:03 +04:00
Timothy Jaeryang Baek 2d5b82df8c enh: include sources field in non-streaming response 2025-05-26 22:22:37 +04:00
Timothy Jaeryang Baek ffa51ece0c refac: pinned chat endpoint 2025-05-26 22:15:21 +04:00
Timothy Jaeryang BaekandClassic298 a0d034fa7c feat: Bypass file creation with CTRL + SHIFT + V
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-05-26 22:10:35 +04:00
Timothy Jaeryang Baek fc5dfd3536 refac 2025-05-26 22:02:40 +04:00
Tim Jaeryang Baek 5d7c89964c Merge pull request #14314 from fl0w1nd/dev
fix: Correctly handle toggle filters to prevent unintended activation
2025-05-26 21:58:57 +04:00
Timothy Jaeryang Baek caa5ad44d4 refac 2025-05-26 19:53:29 +04:00
Tim Jaeryang Baek 040f29d058 Merge pull request #14316 from Yu-QX/main
fix: Handle special cases of LLM output not following markdown syntax
2025-05-26 19:30:48 +04:00
Yu QX bb62bd9c1f Extracting the traversal. 2025-05-26 21:04:08 +08:00
Yu QX b9f74a66b2 Seperate into processChineseContent 2025-05-26 20:43:21 +08:00
Yu QX 254dd4246e Integrate file into index.ts. 2025-05-26 19:35:28 +08:00
Timothy Jaeryang BaekandJason Baker 4da75a9e78 feat: GZip, Brotli, ZStd compression middleware support
Co-Authored-By: Jason Baker <jason.th.baker@gmail.com>
2025-05-26 14:18:29 +04:00
Yu QX 4483fa95b8 Perfect the logic of case 1.1.x 2025-05-26 17:25:33 +08:00
Tim Jaeryang Baek c157e74f0c Merge pull request #14335 from open-webui/main
dev
2025-05-26 13:02:08 +04:00
Tim Jaeryang Baek ada9655f33 Merge pull request #14317 from SadmL/patch-3
[i18n] Russian locale update
2025-05-26 13:01:39 +04:00
Tim Jaeryang Baek 9de92687f6 Merge pull request #14328 from ShirasawaSama/fix_path_leakage_caused_by_file_upload
fix: Fix path leakage caused by file upload
2025-05-26 12:57:21 +04:00
Tim Jaeryang Baek 31dbafd62e Merge pull request #14301 from qingchunnh/Update_zh-CN-25525
i18n: Update & Improve zh-CN
2025-05-26 12:56:14 +04:00
Shirasawa 0dc29a220f fix: Fix path leakage caused by file upload 2025-05-26 12:20:00 +08:00
Link [Связной] 7db9f7b8b6 Update translation.json 2025-05-25 14:53:03 +03:00
Link [Связной] dc50fa3744 [i18n] Russian locale update 2025-05-25 14:42:42 +03:00
YuQX 8ef7938c96 fix: Handle special cases of LLM output not following markdown syntax (20250525, formated) This commit introduces a utility function to address rendering issues in LLM output, particularly for Chinese characters and parentheses. The function ensures minimal modification of the original text while fixing markdown parsing problems. Changes include: - Added in for handling specific cases. - Updated in to incorporate the new utility. The fix ensures proper rendering of bold/italic text containing Chinese parentheses, improving readability for non-English content. 2025-05-25 19:37:43 +08:00
fl0w1nd 332043c38b fix: Correctly handle toggle filters to prevent unintended activation 2025-05-25 17:59:31 +08:00
YuQX 49fe137553 fix: Handle special cases of LLM output not following markdown syntax 20250525
This commit introduces a utility function  to address rendering issues in LLM output, particularly for Chinese characters and parentheses. The function ensures minimal modification of the original text while fixing markdown parsing problems.

Changes include:
- Added  in  for handling specific cases.
- Updated  in  to incorporate the new utility.

The fix ensures proper rendering of bold/italic text containing Chinese parentheses, improving readability for non-English content.
2025-05-25 13:58:32 +08:00
qingchun 6a93b54e8e i18n: Update & Improve zh-CN 2025-05-25 08:02:47 +08:00
Timothy Jaeryang Baek 8598fc9608 chore: format 2025-05-25 02:33:52 +04:00
Timothy Jaeryang Baek 5885d8e275 enh: ability to hide workspace models 2025-05-25 02:32:32 +04:00
Timothy Jaeryang Baek f9d374810d refac 2025-05-25 02:21:03 +04:00
Timothy Jaeryang Baek 358ad8ef20 enh: import/export default prompt suggestions 2025-05-25 02:17:51 +04:00
Timothy Jaeryang Baek 33f2fdb91e refac: styling 2025-05-25 02:07:13 +04:00
Timothy Jaeryang Baek e65ac97c45 refac 2025-05-25 01:59:48 +04:00
Timothy Jaeryang Baek b2b1a21299 refac 2025-05-25 01:48:37 +04:00
Timothy Jaeryang Baek 75208935d7 refac: user chat list modal 2025-05-25 01:44:53 +04:00
Timothy Jaeryang Baek 44e7e09784 refac 2025-05-25 01:28:35 +04:00
Timothy Jaeryang Baekandsilentoplayz 1d53754ea7 refac: chat list modal
Co-Authored-By: silentoplayz <50341825+silentoplayz@users.noreply.github.com>
2025-05-25 01:26:12 +04:00
Timothy Jaeryang Baek 6e8ca96799 enh: archived chats modal 2025-05-25 01:23:12 +04:00
Timothy Jaeryang Baek 5db7d8acd1 refac 2025-05-25 00:58:55 +04:00
Timothy Jaeryang Baek 0fe00ab2c3 refac 2025-05-25 00:52:53 +04:00
Timothy Jaeryang Baek 9b000d82ec refac 2025-05-25 00:51:44 +04:00
Timothy Jaeryang Baek 7e6f1f8848 enh: archived chats modal 2025-05-25 00:48:30 +04:00
Timothy Jaeryang Baek 31e2686ae6 feat: /sync functions endpoint 2025-05-24 23:39:19 +04:00
Timothy Jaeryang Baek cce5f024bd feat: WEBUI_AUTH_TRUSTED_GROUPS_HEADER 2025-05-24 23:17:12 +04:00
Tim Jaeryang Baek 17263a294f Merge pull request #14284 from diwakar-s-maurya/patch-7
feat: add support for table formatting in knowledge collection
2025-05-24 23:02:46 +04:00
Tim Jaeryang Baek 6dbab870e5 Merge pull request #14263 from qingchunnh/Update_zh-CN-25524
i18n: Update & Improve zh-CN
2025-05-24 22:59:47 +04:00
Timothy Jaeryang Baek 9598532270 enh/chore: bug report template 2025-05-24 22:56:54 +04:00
Diwakar Singh Maurya e31a7bcc4c feat: add support for table formatting in knowledge collection 2025-05-24 16:21:55 +00:00
Timothy Jaeryang Baek 261328077d refac 2025-05-24 17:20:40 +04:00
Timothy Jaeryang Baek aebbc2593b refac: scrollbar thumb styling 2025-05-24 17:19:47 +04:00
Tim Jaeryang Baek 8261f0e17e Merge pull request #14276 from Classic298/memories
feat: Make Edit/Add Memory Modal larger, and the text input box size-adjustable
2025-05-24 16:01:07 +04:00
Tim Jaeryang Baek 37d2aed26e Merge pull request #14268 from TiancongLx/dev
i18n: update zh-TW
2025-05-24 15:42:50 +04:00
Classic298 c377ff80c9 Update EditMemoryModal.svelte 2025-05-24 13:33:56 +02:00
Classic298 9bd031dae3 Update AddMemoryModal.svelte 2025-05-24 13:33:49 +02:00
Tiancong Li bc7853d6a4 i18n: update zh-TW 2025-05-24 13:38:32 +08:00
qingchun adefc811c7 i18n: Update & Improve zh-CN 2025-05-24 09:18:18 +08:00
qingchun 3c828f7828 i18n: Update & Improve zh-CN 2025-05-24 08:30:09 +08:00
Timothy Jaeryang Baek da75d0ca1e chore: format 2025-05-24 02:13:54 +04:00
Timothy Jaeryang Baek 51ab02f3af chore: format 2025-05-24 02:13:46 +04:00
Timothy Jaeryang Baek 29d08c2a16 refac: styling 2025-05-24 02:12:44 +04:00
Timothy Jaeryang Baek abcf59ab2b refac: styling 2025-05-24 01:56:02 +04:00
Timothy Jaeryang Baek ffa5db4fec refac: styling 2025-05-24 01:45:55 +04:00
Timothy Jaeryang Baek f95e52a6a6 refac: wording 2025-05-24 01:40:01 +04:00
Timothy Jaeryang Baek 17aca908c5 refac: styling 2025-05-24 01:36:53 +04:00
Tim Jaeryang Baek e663b90a9f Merge pull request #14069 from Ithanil/bm25_weight
feat: Configurable weight for BM25Retriever during hybrid search
2025-05-24 01:13:03 +04:00
Timothy Jaeryang Baek bf97dbe052 refac 2025-05-24 01:12:11 +04:00
Timothy Jaeryang Baek 80eaa24ca8 enh: provide source name in context if available 2025-05-24 00:52:09 +04:00
Timothy Jaeryang Baek 8b5e89eada chore: format 2025-05-24 00:43:38 +04:00
Timothy Jaeryang Baek a46765fd0e refac 2025-05-24 00:43:02 +04:00
Timothy Jaeryang Baek baaa285534 feat: user stt language 2025-05-24 00:36:30 +04:00
Jan Kessler a90d3f3268 add missing locale string 2025-05-23 22:30:04 +02:00
Jan Kessler e70dd33233 rename BM25_WEIGHT -> HYBRID_BM25_WEIGHT 2025-05-23 22:06:44 +02:00
Tim Jaeryang Baek 9946dc7b5f Merge pull request #14252 from Kylapaallikko/dev
i18n: Update fi-FI translation
2025-05-23 23:53:40 +04:00
Timothy Jaeryang Baek 88a296b989 refac: chat reset input behaviour 2025-05-23 23:52:10 +04:00
Kylapaallikko bf12946ac9 Update fi-FI translation.json 2025-05-23 22:12:00 +03:00
Tim Jaeryang Baek 99f88082ca Merge pull request #14248 from diwakar-s-maurya/patch-3
feat: Show tool call parameters along tool ouput
2025-05-23 22:55:08 +04:00
Timothy Jaeryang Baek 6636207e0c refac 2025-05-23 22:10:57 +04:00
Tim Jaeryang Baek c8f1bdf928 Merge pull request #14245 from PVBLIC-F/dev
perf Update mistral.py
2025-05-23 21:57:16 +04:00
Timothy Jaeryang Baek 3f2025dc6e enh: always process file with external document loader 2025-05-23 21:55:09 +04:00
Diwakar Singh Maurya 549237fb05 Show tool call parameters along tool ouput 2025-05-23 17:42:05 +00:00
Timothy Jaeryang Baek 797c7d4e56 refac: allow min_p for openai requests 2025-05-23 21:28:02 +04:00
PVBLIC Foundation bf193dfb5d Update mistral.py 2025-05-23 10:00:19 -07:00
Timothy Jaeryang Baek 30d15c1b4b chore: format 2025-05-23 20:44:41 +04:00
Timothy Jaeryang Baek b9afef85c4 refac: advanced params 2025-05-23 20:44:19 +04:00
Timothy Jaeryang Baek ed0bebc7ef enh: granular audio playback speed 2025-05-23 20:10:16 +04:00
Timothy Jaeryang Baek f75ac5cc01 refac: styling 2025-05-23 20:06:05 +04:00
Timothy Jaeryang Baek 1cf21d3fa2 feat: ollama unload model 2025-05-23 19:45:29 +04:00
Timothy Jaeryang Baek 19b69fcb66 chore: format 2025-05-23 19:22:08 +04:00
Timothy Jaeryang Baek e15ff8d9f6 refac 2025-05-23 19:15:57 +04:00
Timothy Jaeryang Baek 0e6f09a0a9 enh: ollama loaded model display 2025-05-23 19:13:18 +04:00
Timothy Jaeryang Baek a50a8e2ef9 refac: ollama ps 2025-05-23 18:47:50 +04:00
Timothy Jaeryang Baek 65d997a6c5 refac 2025-05-23 18:22:57 +04:00
Timothy Jaeryang Baek 62fbd6c91c refac 2025-05-23 18:17:35 +04:00
Timothy Jaeryang Baek 03288d9fca refac: redesign 2025-05-23 17:30:17 +04:00
Tim Jaeryang Baek 06aa59e2a5 Merge pull request #14219 from Yu-QX/main
Fix: Solve equation problems with Chinese symbols
2025-05-23 17:19:29 +04:00
Timothy Jaeryang Baek 02000b1a6c refac: include help items in user menu 2025-05-23 16:49:11 +04:00
Timothy Jaeryang Baek dd8e742c2d refac: tts details tags 2025-05-23 13:40:33 +04:00
Tim Jaeryang Baek 55a6fae9db Merge pull request #14210 from ShrootBuck/main
refac: improve prompt formatting for selected text in FloatingButtons
2025-05-23 13:14:27 +04:00
Tim Jaeryang Baek bdcd04a78c Merge pull request #14212 from qingchunnh/Update_zh-CN-25523
i18n: Update & Improve zh-CN
2025-05-23 13:13:19 +04:00
Suleiman Elkhoury e10d6ad79c Fix S3 allowed characters in Tags.
In Amazon S3 storage, only the following charaters allowed in Tagging "letters (a-z, A-Z), numbers (0-9), and spaces representable in UTF-8, and the following characters: + - = . _ : / @". Added a sanitizer function to clear tags before the put request.
2025-05-23 11:09:40 +02:00
Tim Jaeryang Baek 9c4ce0273f Merge pull request #14215 from qingchunnh/Update_zh-TW-25523
i18n: Update zh-TW
2025-05-23 13:09:23 +04:00
qingchun 3d2a26fe0b i18n: Update & Improve zh-CN 2025-05-23 16:54:20 +08:00
qingchun aa6648ba8a i18n: Update & Improve zh-CN 2025-05-23 15:46:03 +08:00
YuQX ec97fdf5d6 Fix: Solve equation problems with Chinese symbols by adding more symbols to the ALLOWED_SURROUNDING_CHARS 2025-05-23 15:06:37 +08:00
qingchun 4025c96d0f i18n: Update zh-TW 2025-05-23 12:39:45 +08:00
qingchun ed9a2c295c i18n: Update & Improve zh-CN 2025-05-23 12:34:30 +08:00
qingchun 28e44452dd i18n: Update & Improve zh-CN 2025-05-23 09:51:05 +08:00
Zayd Krunz 1d382b83c8 refac: improve prompt formatting for selected text in FloatingButtons 2025-05-23 01:16:47 +00:00
Timothy Jaeryang Baek a2f12db8d9 refac: memories 2025-05-23 03:26:14 +04:00
Timothy Jaeryang Baek 7071a03419 chore: format 2025-05-23 02:54:44 +04:00
Timothy Jaeryang Baek 7a593b63b2 fix: image generation with allowed file extensions 2025-05-23 02:53:08 +04:00
Timothy Jaeryang Baek 4c55ad71a7 fix: local/external models 2025-05-23 02:48:31 +04:00
Timothy Jaeryang Baek 1f632d3570 fix: remove leading dot for file extension check 2025-05-23 02:39:19 +04:00
2eca6f6414 feat: bypass web loader in web search
Co-Authored-By: Perry Li <peiyaoli@mail.nankai.edu.cn>
Co-Authored-By: WilliamGates <3852641+williamgateszhao@users.noreply.github.com>
2025-05-23 02:30:35 +04:00
Timothy Jaeryang Baek 0b7f927983 enh: sortable banners 2025-05-23 02:02:11 +04:00
Timothy Jaeryang Baek a68ec8822a enh: filter md image for task generations 2025-05-23 01:33:08 +04:00
Timothy Jaeryang BaekandTornike Gurgenidze aac25eac9e refac: reranker
Co-Authored-By: Tornike Gurgenidze <togurg14@freeuni.edu.ge>
2025-05-23 01:29:48 +04:00
Timothy Jaeryang Baek f2d2cae8fb enh: help menu 2025-05-23 01:16:08 +04:00
Timothy Jaeryang Baek 71288ea3f2 refac 2025-05-23 01:11:44 +04:00
Timothy Jaeryang Baek cbe84d7139 enh: token expiry buffer 2025-05-23 01:08:28 +04:00
Timothy Jaeryang Baek d4c3cfbaba refac: oauth redirect url to use WEBUI_URL 2025-05-23 01:03:28 +04:00
Tim Jaeryang Baek 336d0b45ad Merge pull request #14165 from Zyfax/patch-1
fix: image model list
2025-05-22 15:37:00 +04:00
Tim Jaeryang Baek da4aa5f08b Merge pull request #14152 from U8F69/fix_user_auth
fix(auth): correctly use password hash when duplicate email records exist
2025-05-22 14:58:10 +04:00
Zyfax 7489bc6126 fix: image model list
OpenAI image model added:
gpt-image-1

Gemini image model renamed:
id: imagen-3-0-generate-002 to imagen-3.0-generate-002
2025-05-22 11:07:46 +02:00
Tim Jaeryang Baek 4519e4eed1 Merge pull request #14158 from itk-dev/feature/4347-danishtranslations
i18n: Updated danish translations
2025-05-22 12:47:22 +04:00
Timothy Jaeryang Baek 787987d2f0 refac/fix: optional tool param type 2025-05-22 12:41:00 +04:00
Tim Jaeryang Baek 17ed331a0a Merge pull request #14126 from SadmL/patch-2
[i18n] Russian locale update
2025-05-22 12:38:01 +04:00
Tim Jaeryang Baek b339fd6fed Merge pull request #14136 from Vombit/main
[i18n] Fixed a typo in the translation
2025-05-22 12:37:31 +04:00
Tim Jaeryang Baek 0eda03bd3c Merge pull request #14147 from PVBLIC-F/dev
perf Update pinecone.py
2025-05-22 12:19:14 +04:00
Tim Jaeryang Baek a935e375f3 Merge pull request #14149 from silentoplayz/i18n-fix
fix: $i18n.t fix
2025-05-22 11:49:09 +04:00
U8F69 dd6124a84f fix(auth): fix invalid password use in auth 2025-05-22 11:03:43 +08:00
Silentoplayz 558b2b1016 Update General.svelte 2025-05-21 20:41:19 -04:00
PVBLIC Foundation 86e24bb4aa Update pinecone.py
I've improved the pinecone.py file by:
Updated from the deprecated PineconeGRPC client to the newer Pinecone client
Modified the client initialization code to match the new API requirements
Added better response handling with getattr() to safely access attributes from response objects
Removed the streaming_upsert method which is not available in the newer client
Added safer attribute access with fallbacks throughout the code
Updated the close method to reflect that the newer client doesn't need explicit closing
These changes ensure the code is compatible with the latest Pinecone Python SDK and will be more robust against future changes. The key improvement is migrating away from the deprecated gRPC client which will eventually stop working.
2025-05-21 15:28:42 -07:00
Vombit ff46fa5b33 fix: fixed a typo in the translation 2025-05-21 21:15:42 +05:00
lilosti bd40cceb2d Updated danish translations 2025-05-21 14:52:33 +02:00
Link [Связной] 9201fd4652 [i18n] Russian locale update 2025-05-21 14:48:57 +03:00
Tim Jaeryang Baek 65a44a3900 Merge pull request #14099 from Classic298/ShiftHoldingFix
fix: Holding Shift Bug fixed
2025-05-21 13:46:58 +04:00
Tim Jaeryang Baek d2e426990b Merge pull request #14110 from qingchunnh/Update_zh-CN-25521
i18n: update and improve zh-CN
2025-05-21 13:43:00 +04:00
qingchun ed0538e643 i18n: update and improve zh-CN 2025-05-21 07:34:07 +08:00
Timothy Jaeryang Baek e3e7eb96f6 refac 2025-05-20 23:55:11 +04:00
Tim Jaeryang Baek a45f2cd0a9 Merge pull request #14104 from bassettb/bryan/parse-db-user-chars
fix: handle escaped characters in DATABASE_URL username
2025-05-20 23:51:41 +04:00
Timothy Jaeryang Baek 6121d4696a enh: search input arrow controls 2025-05-20 23:47:41 +04:00
Timothy Jaeryang Baek b78354100f refac 2025-05-20 23:33:41 +04:00
Timothy Jaeryang Baek 512345f40e refac: chat transition 2025-05-20 23:33:23 +04:00
Bryan Bassett 12896fb728 use unquote_user in peewee 3.17.10 2025-05-20 15:18:32 -04:00
DrMelone 8b80555adf fix attempt #1 2025-05-20 20:03:21 +02:00
Tim Jaeryang Baek 4e4b5ab83b Merge pull request #14096 from silentoplayz/add-clear-button-to-remaining-pages
feat: Add Clear button to Functions and Models pages search bar
2025-05-20 21:30:07 +04:00
Silentoplayz 03eeb75bfe feat: Add Clear button for Function and admin Model pages 2025-05-20 12:21:32 -04:00
Timothy Jaeryang Baek 74ace200fe fix/refac: functions multi-replica issue 2025-05-20 20:20:27 +04:00
Tim Jaeryang Baek 2b5c015bcb Merge pull request #14093 from open-webui/main
dev
2025-05-20 19:48:26 +04:00
Timothy Jaeryang Baek 82716f3789 refac 2025-05-20 19:39:18 +04:00
Timothy Jaeryang Baek a8a9b35153 refac: file item modal 2025-05-20 19:29:31 +04:00
Tim Jaeryang Baek d3c7628092 Merge pull request #14059 from sreesdas/main
fix: resolve issue where external document loader was not invoked
2025-05-20 17:43:06 +04:00
Tim Jaeryang Baek 86f1aee20b Merge pull request #14054 from silentoplayz/delete-lightblub
fix: Correct typo in LightBulb component name and imports & delete lightblub.svelte (duplicate of lightbulb.svelte)
2025-05-20 17:25:42 +04:00
Tim Jaeryang Baek f9a66afe76 Merge pull request #14072 from torisetxd/alignment-fix
fix: Alignment issues with Text in Buttons in UI
2025-05-20 17:24:49 +04:00
Tim Jaeryang Baek fac5884d8c Merge pull request #14073 from tth37/fix_default_web_loader_verify_ssl
fix: Default web loader fail silently when `verify_ssl=False`
2025-05-20 17:24:22 +04:00
Tim Jaeryang Baek 6035664048 Merge pull request #14051 from Classic298/german-translation
i18n: German translation overhaul
2025-05-20 17:23:55 +04:00
Tim Jaeryang Baek 75a31e116a Merge pull request #14049 from mjmendo/web_search_optional_fields
fix: web search optional fields
2025-05-20 17:22:52 +04:00
tth37 78befd5a2f fix: Default web loader fail when verify_ssl=False 2025-05-20 19:44:18 +08:00
toriset b735042ecc Fix alignment issues with Buttons in UI 2025-05-20 14:33:04 +03:00
Jan Kessler 308d8ac04a make bm25_weight a regular parameter of query_doc.. / get_sources_from_files functions 2025-05-20 11:46:32 +02:00
Jan Kessler b5ddaf6417 make weight for bm25 retriever in hybrid search ui-configurable 2025-05-20 10:39:31 +02:00
Classic298 8d94a59f27 task models 2025-05-20 08:39:36 +02:00
Classic298 479b5ad820 fixes after feedback 2025-05-20 08:31:49 +02:00
sree f408b08965 minor bug fix for external document loader not working 2025-05-20 11:10:23 +05:30
Silentoplayz ab9486603c chore: fix on-hover visual 2025-05-19 22:42:15 -04:00
Silentoplayz 58b309149a chore: adjustment 2025-05-19 17:21:43 -04:00
Silentoplayz 83ed2d0661 chore: delete lightblub.svelte 2025-05-19 17:12:36 -04:00
DrMelone 4b538047dd various i18n fixes
typos, incomplete sentences fixed, wrong translations, inconsistencies fixed
2025-05-19 19:35:08 +02:00
DrMelone fd2ff5e777 i18n german update 1 2025-05-19 19:28:12 +02:00
Marcelo Mendoza 897727c7eb Merge branch 'open-webui:main' into web_search_optional_fields 2025-05-19 17:25:14 +02:00
Marcelo Mendoza d6ad96affb fix: use get method for title and snippet in search results 2025-05-19 17:24:47 +02:00
Tim Jaeryang Baek 5613cda5cf Merge pull request #14048 from open-webui/main
dev
2025-05-19 19:17:39 +04:00
Timothy Jaeryang Baek ed0659aca6 refac 2025-05-19 19:17:08 +04:00
Timothy Jaeryang Baek fd91b277a4 refac: pypi 2025-05-19 14:48:55 +04:00
Timothy Jaeryang Baek d2758c7fa2 refac: pypi 2025-05-19 14:35:51 +04:00
Tim Jaeryang Baek ef3232a24f Merge pull request #14023 from TiancongLx/dev
i18n: update zh-TW
2025-05-19 14:22:31 +04:00
Tim Jaeryang Baek 438eb90671 Merge pull request #14024 from hurxxxx/i18n/update-ko-KR
i18n: update ko-KR
2025-05-19 14:22:13 +04:00
Tim Jaeryang Baek c9bbfaee9d Merge pull request #14033 from SadmL/patch-2
[i18n] Russian locale update
2025-05-19 14:22:03 +04:00
Link [Связной] 1d0921de0d [i18n] Russian locale update 2025-05-19 11:24:08 +03:00
hurxxxx c1066d7e08 i18n: update ko-KR 2025-05-19 13:28:45 +09:00
Tiancong Li 23991c32c5 i18n: update zh-TW 2025-05-19 12:09:07 +08:00
Tim Jaeryang Baek e6afa69f59 Merge pull request #13887 from open-webui/dev
0.6.10
2025-05-19 05:34:23 +04:00
Timothy Jaeryang Baek 2b1c3b5208 Update CHANGELOG.md 2025-05-19 05:27:29 +04:00
Timothy Jaeryang Baek b905b73935 Update CHANGELOG.md 2025-05-19 05:24:03 +04:00
Timothy Jaeryang Baek fa5cd59ef8 doc: changelog 2025-05-19 05:20:57 +04:00
Timothy Jaeryang Baek 030c20feff refac 2025-05-19 05:12:53 +04:00
Tim Jaeryang Baek 8fb7b980a2 Merge pull request #14019 from silentoplayz/clear
feat: Improve remaining export buttons with total item count
2025-05-19 04:33:34 +04:00
Timothy Jaeryang Baek eafb8e014a chore: format 2025-05-19 04:32:00 +04:00
Timothy Jaeryang Baek 2ab5aa4d34 refac: azure openai 2025-05-19 04:31:04 +04:00
Silentoplayz 5cb1475e29 fix: apply change to functions and models pages 2025-05-18 20:12:53 -04:00
Timothy Jaeryang Baek 2f2a7ee0b0 chore: format 2025-05-19 03:58:42 +04:00
Timothy Jaeryang Baek 2e56b1f13d refac 2025-05-19 03:55:56 +04:00
Timothy Jaeryang Baek caeb822cdc feat: azure openai support 2025-05-19 03:40:32 +04:00
Timothy Jaeryang Baek 47f8b3500b feat: azure openai connections ui 2025-05-19 03:29:14 +04:00
Timothy Jaeryang Baek 73e64fe7fb refac: audio upload handling 2025-05-19 02:52:48 +04:00
Timothy Jaeryang Baekandsilentoplayz aee9d05916 enh: workspace search clear button
Co-Authored-By: silentoplayz <50341825+silentoplayz@users.noreply.github.com>
2025-05-19 02:36:15 +04:00
Timothy Jaeryang Baek 3af289d196 refac 2025-05-19 02:32:06 +04:00
Timothy Jaeryang Baek 9f9fb3403d refac 2025-05-19 02:07:41 +04:00
Timothy Jaeryang Baek c6580a2a02 refac 2025-05-19 02:01:07 +04:00
Timothy Jaeryang Baek 417ba4980a chore: format 2025-05-19 01:55:02 +04:00
Timothy Jaeryang Baek 0c6f22b20b refac 2025-05-19 01:54:26 +04:00
Timothy Jaeryang Baek 96c7a0ac3c refac 2025-05-19 01:52:40 +04:00
Timothy Jaeryang Baek e895c33baa refac 2025-05-19 01:47:12 +04:00
Timothy Jaeryang Baek fbfd3ff134 refac 2025-05-19 01:44:27 +04:00
Timothy Jaeryang Baek 5a40feea1c enh: search shortcut 2025-05-19 01:40:51 +04:00
Timothy Jaeryang Baek 1f5860c4ab refac 2025-05-19 01:40:10 +04:00
Timothy Jaeryang Baek d069d1264d feat: search modal 2025-05-19 01:39:33 +04:00
Tim Jaeryang Baek 4f4811adb1 Merge pull request #14017 from athoik/title_tags
feat: read max_tokens from model config with fallback to 1000 for title and tag generation
2025-05-19 00:43:05 +04:00
Timothy Jaeryang Baek 05b79ce221 refac: model selection 2025-05-19 00:41:49 +04:00
Timothy Jaeryang Baekandsilentoplayz 18d6311951 feat: export count
Co-Authored-By: silentoplayz <50341825+silentoplayz@users.noreply.github.com>
2025-05-19 00:15:07 +04:00
Timothy Jaeryang Baek 8f4104fb7a refac 2025-05-19 00:13:03 +04:00
Tim Jaeryang Baek 349b0ca0b7 Merge pull request #14002 from silentoplayz/search-by-user-or-email
feat: enhance search with username/email search across workspace pages
2025-05-19 00:12:11 +04:00
Tim Jaeryang Baek 2dde4102ad Merge pull request #14009 from TiancongLx/dev
i18n: update and improve zh-TW
2025-05-19 00:11:32 +04:00
Athanasios Oikonomou eabdd4a140 feat: read max_tokens from model config with fallback to 1000 for title and tag generation
Improves title and tag generation by using the max_tokens value from the model configuration when available, with a fallback to the previous default of 1000.

This change is necessary for models like Gemini Pro that generate longer responses and require a higher token limit to successfully generate titles or tags.
2025-05-18 22:45:33 +03:00
Tiancong Li 4667328d40 i18n: update zh-TW 2025-05-18 21:16:16 +08:00
Tim Jaeryang Baek 56740ab8d6 Merge pull request #13996 from Davixk/update-IT-translation
i18n: add and improve Italian translations
2025-05-18 15:04:54 +04:00
Silentoplayz 95badda1c2 feat: username and email search in workspace pages 2025-05-18 03:23:38 -04:00
Dave ebf2e4141e Merge remote-tracking branch 'upstream/dev' into update-IT-translation 2025-05-18 04:46:41 +02:00
Dave a8813c1a69 i18n: Update and Improve Italian translations 2025-05-18 04:36:12 +02:00
Tim Jaeryang Baek fbcc80485b Merge pull request #13972 from jarrod-lowe/otel-attrs
feat: Add user details to opentelemetry spans
2025-05-17 21:54:56 +04:00
Timothy Jaeryang Baek 765acc4cad chore: dep bump 2025-05-17 21:53:31 +04:00
Timothy Jaeryang Baek 9801a2d23f refac 2025-05-17 03:14:26 +04:00
Timothy Jaeryang Baek 424f6f71cf chore: duckduckgo-search bump 2025-05-17 03:03:07 +04:00
Timothy Jaeryang Baek b280f828b0 enh: very long audio transcription 2025-05-17 02:51:28 +04:00
Timothy Jaeryang Baek d54e588ec3 refac 2025-05-17 02:26:28 +04:00
Timothy Jaeryang Baek 08e4c163ea feat: local/external connections 2025-05-17 01:47:48 +04:00
Timothy Jaeryang Baek 000906cd99 chore: format 2025-05-17 01:13:36 +04:00
Timothy Jaeryang Baek c3acb30bb1 feat: model file upload capability 2025-05-17 01:13:13 +04:00
Jarrod Lowe df853246f3 Add user details to otel span 2025-05-17 09:11:26 +12:00
Timothy Jaeryang Baek 6692fb2181 chore: format 2025-05-17 01:00:37 +04:00
Timothy Jaeryang Baek b14398481d enh: model capabilities 2025-05-17 00:59:00 +04:00
Timothy Jaeryang Baek 7df6d7f325 refac/fix: signout redirect flow 2025-05-17 00:38:39 +04:00
Timothy Jaeryang Baek fcb93f3155 feat: artifacts download button 2025-05-17 00:27:20 +04:00
Timothy Jaeryang Baek 02e5bbc903 refac 2025-05-17 00:19:08 +04:00
Timothy Jaeryang Baek 359bcb837d refac 2025-05-17 00:08:03 +04:00
Timothy Jaeryang Baek ecae944fd0 refac 2025-05-16 23:59:24 +04:00
Timothy Jaeryang Baek 05f284d4a6 refac 2025-05-16 23:41:51 +04:00
Timothy Jaeryang Baek ea723374c7 refac 2025-05-16 23:36:33 +04:00
Timothy Jaeryang Baek 1f38350128 feat: toggle filter middleware 2025-05-16 23:33:02 +04:00
Timothy Jaeryang Baek e37433f2b1 refac 2025-05-16 23:21:08 +04:00
Timothy Jaeryang Baek b61efcf54a feat: toggle filter 2025-05-16 22:59:02 +04:00
Timothy Jaeryang Baek 32b386e8d0 refac 2025-05-16 22:44:47 +04:00
Timothy Jaeryang Baek 7bbeaec93b revert 2025-05-16 22:43:42 +04:00
Timothy Jaeryang Baek 34ef249a10 revert: remove reasoning capability 2025-05-16 22:24:23 +04:00
Timothy Jaeryang Baek 363ac35c09 refac 2025-05-16 22:17:47 +04:00
Tim Jaeryang Baek 50bc6d9b12 Merge pull request #13919 from dongfangzan/main
feat: add switching thinking/non-thinking modes to models
2025-05-16 22:08:04 +04:00
Timothy Jaeryang Baek 27bb5f5369 fix/refac: tts remove formattings 2025-05-16 21:51:29 +04:00
Timothy Jaeryang Baek 54dc24986f feat: preview html 2025-05-16 21:47:43 +04:00
Timothy Jaeryang Baek 07b5e84221 refac 2025-05-16 21:29:50 +04:00
Timothy Jaeryang Baek 2bd7db12a2 enh: ALLOWED_FILE_EXTENSIONS ui 2025-05-16 21:05:52 +04:00
Timothy Jaeryang Baek 528a2cf96a refac 2025-05-16 18:19:28 +04:00
Timothy Jaeryang Baek 2aa5e73728 refac: filter details 2025-05-16 18:01:55 +04:00
Timothy Jaeryang Baek dec9dd1ac0 refac: web search queries 2025-05-16 17:11:42 +04:00
Timothy Jaeryang Baek bb06df80a2 refac 2025-05-16 17:07:52 +04:00
Timothy Jaeryang Baek 0b761776c4 enh: high contrast mode 2025-05-16 17:06:21 +04:00
Timothy Jaeryang Baek 2450f2c5d8 chore: format 2025-05-16 16:57:30 +04:00
Timothy Jaeryang Baek 62180b15e1 refac: command arrow up/down behaviour 2025-05-16 15:39:26 +04:00
Tim Jaeryang Baek c201b4d689 Merge pull request #13931 from itk-dev/feature/better-frontend-error
WIP: Minor clean up of console log usage in the front end
2025-05-16 13:33:16 +04:00
Tim Jaeryang Baek bc66755fb0 Merge pull request #13950 from uryukikutani/feature/screen-reader
fix: made the chat response messages readable with a screen reader
2025-05-16 13:32:40 +04:00
Tim Jaeryang Baek e74c06d2f5 Merge pull request #13929 from mini-777/feat--Korean-translations-for-clarity-and-accuracy
i18n : korean translations for clarity and accuracy
2025-05-16 13:32:10 +04:00
uryukikutani 53dcaa63d4 made the chat response messages readable with a screen reader 2025-05-16 17:41:56 +09:00
Tim Jaeryang Baek 186a89bcba Merge pull request #13937 from tanerim/patch-1
Update translation.json
2025-05-16 10:23:47 +04:00
dongfangzan 351f0186b9 fix: typo 2025-05-16 08:05:49 +08:00
Taner Sezer 21e96247a9 Update translation.json
Added new translations
2025-05-16 01:19:53 +03:00
Jesper Kristensen 84e0605835 Cleaning up usage of console log in front end 2025-05-15 21:53:07 +02:00
Jesper Kristensen 20c1c3f66b Changed console logging of errors to use console error logging 2025-05-15 21:35:07 +02:00
mini-777 2a4fe1335e i18n: update Korean translations with new entries and corrections 2025-05-16 02:45:19 +09:00
mini-777 e24e22debe Merge commit 'e4179aa114fecc8d35ba48f091ce7c9abcc96065' into HEAD 2025-05-16 02:10:01 +09:00
mini-777 208b86ae53 feat: add new notes feature with editor and list components, and update routing for notes management 2025-05-16 02:06:36 +09:00
Tim Jaeryang Baek e4179aa114 Merge pull request #13922 from TiancongLx/dev
i18n: update zh-TW
2025-05-15 19:02:37 +04:00
Tiancong Li df5ef8e553 i18n: update zh-TW 2025-05-15 21:15:22 +08:00
dongfangzan 9b93b81f84 feat: add switching thinking/non-thinking modes to models 2025-05-15 19:56:10 +08:00
dongfangzan a7c90925ef test contribute 2025-05-15 19:31:40 +08:00
Tim Jaeryang Baek ca2cfb5bad Merge pull request #13899 from loitragg/apply-multitenant-for-qdrant
perf: implement multi-tenancy for Qdrant vector store client
2025-05-15 13:37:36 +04:00
Kiet Trinh 418ac1a8da refac: Rename Qdrant multi-tenancy variable for improved clarity and consistency 2025-05-15 09:09:24 +00:00
Tim Jaeryang Baek db24af6356 Merge pull request #13902 from yo-nagase/dev
i18n: Update Japanese translation
2025-05-15 12:59:17 +04:00
Timothy Jaeryang Baek 72b2555953 refac 2025-05-15 12:58:44 +04:00
Kiet Trinh 485bd7666c fix: Update Qdrant multi-tenancy variable name for consistency in configuration 2025-05-15 08:02:58 +00:00
Nagase Yoshimasa 6eb772c741 i18n: Update Japanese translation 2025-05-15 15:41:04 +09:00
LoiTra 184d8dfd7e feat: Implement Qdrant multi-tenancy support with collection management and tenant isolation 2025-05-15 11:28:06 +07:00
Timothy Jaeryang Baek 0a8cecfbfa feat: response watermark 2025-05-14 23:53:28 +04:00
Timothy Jaeryang Baek b143c71da2 refac: AIOHTTP_CLIENT_SESSION_SSL 2025-05-14 23:33:52 +04:00
Timothy Jaeryang Baek 04287eb6d6 refac 2025-05-14 23:27:34 +04:00
Timothy Jaeryang Baek 39b06dc3b6 refac 2025-05-14 23:13:06 +04:00
Tim Jaeryang Baek e358a66fb1 Merge pull request #13852 from fizyr-forks/self-signed-cert
feat: Add option to disable certificate validation for LDAP certificates.
2025-05-14 23:12:24 +04:00
Timothy Jaeryang Baek a6624a4b16 feat: RAG_ALLOWED_FILE_EXTENSIONS 2025-05-14 23:06:33 +04:00
Timothy Jaeryang Baek 7e241a3904 refac 2025-05-14 22:48:23 +04:00
Timothy Jaeryang Baek 42382b5167 fix 2025-05-14 22:46:01 +04:00
Timothy Jaeryang Baek 8732b64b6b feat: external document loader support 2025-05-14 22:28:40 +04:00
Timothy Jaeryang Baek 32ea31144e refac: file upload handling behaviour 2025-05-14 22:00:42 +04:00
Timothy Jaeryang Baek b80f0751f6 refac 2025-05-14 21:59:17 +04:00
Timothy Jaeryang Baek 0280ee30c7 chore: format 2025-05-14 21:56:22 +04:00
Timothy Jaeryang Baek 8fe4f789d4 fix 2025-05-14 21:53:46 +04:00
Timothy Jaeryang Baek dacf7e015d i18n: revert 2025-05-14 21:52:42 +04:00
Timothy Jaeryang Baek ac83568194 chore: format 2025-05-14 21:40:51 +04:00
Timothy Jaeryang Baek 10108fcd04 chore: format 2025-05-14 21:39:50 +04:00
Timothy Jaeryang Baek 74ccec2812 refac 2025-05-14 21:39:17 +04:00
Tim Jaeryang Baek 29ae231ee0 Merge pull request #13889 from Classic298/pendingtext
feat: Custom Title and Text for Account Activation Screen for pending users
2025-05-14 21:28:16 +04:00
Timothy Jaeryang Baek de70d0cb64 feat: docling do picture description support 2025-05-14 21:26:49 +04:00
DrMelone 3974e53cc3 i18n 2025-05-14 19:11:53 +02:00
DrMelone 92140118f2 revert i18n 2025-05-14 19:07:45 +02:00
DrMelone e9eb661b83 fix + i18n 2025-05-14 18:58:54 +02:00
DrMelone bdb3778f8b fix 2025-05-14 18:10:23 +02:00
Timothy Jaeryang Baek 6e8481e157 fix: evaluation feedback endpoints 2025-05-14 20:08:49 +04:00
DrMelone 143cb4cc34 feat custom text and title 2025-05-14 17:58:20 +02:00
Tim Jaeryang Baek 2217fc4fd9 Merge pull request #13883 from alpha-pet/fix-workspace-model-unintended-toggle
Fix: Unintentional toggling of model status on the workspace page.
2025-05-14 18:09:16 +04:00
Tim Jaeryang Baek 8029bf0dca Merge pull request #13881 from hwzhuhao/feat/vector-db
feat: Add vector type and vector factory class for vector database integration
2025-05-14 17:41:39 +04:00
Thomas Rehn 6b6ce36c96 fix: add unique key to model list to avoid state confusion when filtering 2025-05-14 15:33:32 +02:00
hwzhuhao 6f869ded43 feat:Add vector type and vector factory class for vector database integration 2025-05-14 21:30:50 +08:00
Tim Jaeryang Baek 4959e91fa7 Merge pull request #13832 from Ithanil/svelte_versioning
feat: Handle frontend updates according to Svelte docs
2025-05-14 17:19:32 +04:00
Tim Jaeryang Baek f77a076c16 Merge pull request #13882 from itk-dev/feature/enable_direct_connections
chore: Added missing "enable_direct_connections" to config type
2025-05-14 17:18:04 +04:00
Jesper Kristensen 8b194bc781 Added missing "enable_direct_connections" to config type 2025-05-14 14:53:36 +02:00
Tim Jaeryang Baek bd7d2978f1 Merge pull request #13846 from hary777/patch-2
i18n update translation.json fix typo "amin" in cs-CZ
2025-05-14 15:27:40 +04:00
Tim Jaeryang Baek c7e3ae9f39 Merge pull request #13812 from hwzhuhao/refactor/log
refactor: replace print statements with logging
2025-05-14 15:27:13 +04:00
Timothy Jaeryang Baek 92a8ea743a refac: form data edge case 2025-05-14 15:22:38 +04:00
Timothy Jaeryang Baek 3c4accaeb3 refac/security: ollama model upload path traversal 2025-05-14 15:17:07 +04:00
Timothy Jaeryang Baek a9c22bffc9 refac/security: pipelines upload path traversal 2025-05-14 15:15:05 +04:00
Tim Jaeryang Baek d89e37cc7c Merge pull request #13866 from itk-dev/feature/contrast-chat-input
feat: Contrast enhancements
2025-05-14 15:12:00 +04:00
Sine Jespersen 8eacd26573 add placeholder dark mode 2025-05-14 12:50:47 +02:00
Sine Jespersen 90bcf45b7b placeholder contrast changed 2025-05-14 10:24:09 +02:00
Yeyeong Cho bbb4e75df8 Merge pull request #2 from mini-777/i18n-ko-rewriting
feat(openwebui) : improve Korean translations for better readability Fixes mini-777/8dobibim#2
2025-05-14 17:23:48 +09:00
Sine Jespersen b052b5cced remove whitespace 2025-05-14 10:15:31 +02:00
Sine Jespersen 5db783d67b improve suggestions contrast 2025-05-14 09:52:44 +02:00
Sine Jespersen 43d0db5eea model selector contrast 2025-05-14 09:52:10 +02:00
Sine Jespersen 41f64da0d6 update message input contrast 2025-05-14 09:51:38 +02:00
Sine Jespersen 5cd0b99ae0 update toast contrast 2025-05-14 09:51:07 +02:00
mini-777 571d078a82 fix: improve Korean translations for better readability (fixes mini-777/8dobibim#2) 2025-05-14 15:37:59 +09:00
mini-777 f72fa9b269 fix: update Korean translations for clarity and accuracy 2025-05-14 15:28:46 +09:00
Hans Gaiser f63e76e653 Add option to disable certificate validation. 2025-05-13 17:38:06 +02:00
hary777 66d73cb1de Update translation.json fix typo
Fix typo "amin". I can not look at this anymore.
2025-05-13 15:24:48 +02:00
Tim Jaeryang Baek 950f3cdcc0 Merge pull request #13838 from TiancongLx/dev
i18n: update, complete, and optimize zh-TW
2025-05-13 16:53:52 +04:00
Tim Jaeryang Baek f907e7c979 Merge pull request #13836 from itk-dev/feature/build-watch
build: Added build:watch command to package.json
2025-05-13 16:53:24 +04:00
Jesper Kristensen 24c1b8911c Added build:watch script/command to package.json
Should make it eaiser to automatically build the front-end when
doing local development in docker node containers.
2025-05-13 10:36:48 +02:00
Tiancong Li b41fc1d2f4 i18n: update zh-TW 2025-05-13 16:30:59 +08:00
Jan Kessler dfd4037132 better handling of frontend updates, following svelte docs 2025-05-13 09:51:51 +02:00
Timothy Jaeryang Baek a904f874ee fix: admin signup logic 2025-05-12 20:03:40 +04:00
hwzhuhao fb2950201a refactor: replace print statements with logging 2025-05-12 22:18:47 +08:00
Timothy Jaeryang Baek e9b799676b chore: format 2025-05-12 17:34:33 +04:00
Tim Jaeryang Baek c7f743ddb7 Merge pull request #13810 from phpia/phpia-patch-1
i18n es
2025-05-12 17:31:18 +04:00
phpia 16a9210321 Update translation for mantain prompt
This pull request updates the Spanish localization by replacing all instances of "indicador"/"indicadores" with the more accurate terms "prompt"/"prompts" throughout the UI, documentation, and messages. 

Reason and Rationale: 

In the context of AI and Large Language Models (LLMs), the words "prompt" and "prompts" have become standardized terms worldwide, including within the Spanish-speaking technical community. While "indicador" is a valid literal translation of "prompt" in general Spanish, in generative AI contexts it is potentially confusing, since "indicador" more commonly refers to a gauge, dashboard indicator, or measuring device. This can cause misunderstanding for users, especially those familiar with AI concepts, who expect the term "prompt" to refer to the input text given to a model.
2025-05-12 14:59:15 +02:00
Sung Min c049837a11 Merge pull request #1 from mini-777/i18n-ko-missing
feat(openwebui) : add missing Korean translation strings Fixes mini-777/8dobibim#1
2025-05-12 20:55:14 +09:00
Tim Jaeryang Baek ca34ba318e Merge pull request #13801 from aleixdorca/dev
i18n: Update catalan translation.json
2025-05-12 14:56:19 +04:00
Tim Jaeryang Baek 0d8e24320e Merge pull request #13794 from Lutherwaves/dev
i18n: Bulgarian translations
2025-05-12 14:55:10 +04:00
Aleix Dorca b7ef9a03ac Update catalan translation.json 2025-05-12 08:56:52 +02:00
Tim Jaeryang Baek 4ce1e88750 Merge pull request #13795 from open-webui/dev
refac
2025-05-12 01:10:34 +04:00
Timothy Jaeryang Baek f32b042a00 refac 2025-05-12 01:08:49 +04:00
lutherwaves 792beb095e i18n: Bulgarian translations 2025-05-11 23:58:31 +03:00
Timothy Jaeryang Baek 4c06683e95 refac: Vazirmatn 2025-05-12 00:05:04 +04:00
Tim Jaeryang Baek 0cef844168 Merge pull request #13758 from open-webui/dev
0.6.9
2025-05-10 23:04:48 +04:00
Timothy Jaeryang Baek c56d6963b0 doc: changelog 2025-05-10 23:01:48 +04:00
Timothy Jaeryang Baek 92e2cfdd2f refac 2025-05-10 23:00:53 +04:00
Timothy Jaeryang Baek 3ad5a7d97c chore: bump 2025-05-10 22:48:26 +04:00
Timothy Jaeryang Baek 867fddb821 chore: format 2025-05-10 22:45:58 +04:00
Timothy Jaeryang Baek de69c08a1d enh: alert private task model 2025-05-10 22:43:53 +04:00
Timothy Jaeryang Baek 30cc51ec82 feat: edit message files 2025-05-10 22:27:39 +04:00
Timothy Jaeryang Baek ba69c751ca refac: temporary chat behaviour 2025-05-10 22:12:36 +04:00
Timothy Jaeryang Baek be85f7cc35 fix: confirm dialog focus 2025-05-10 22:09:05 +04:00
Tim Jaeryang Baek 4191ec692e Merge pull request #13751 from open-webui/dev
fix: external reranker
2025-05-10 19:33:59 +04:00
Timothy Jaeryang Baek 6b5f99bf66 fix: external reranker 2025-05-10 19:33:34 +04:00
Yeyeong Cho 13e7a3dee9 fix:add missing Korean translation strings Fixes mini-777/8dobibim#1 2025-05-09 17:54:50 +09:00
242 changed files with 15258 additions and 6238 deletions
+29 -8
View File
@@ -89,9 +89,20 @@ body:
required: true
- label: I have included the Docker container logs.
required: true
- label: I have listed steps to reproduce the bug in detail.
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
required: true
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
required: true
- label: |
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
- Start with the initial platform/version/OS and dependencies used,
- Specify exact install/launch/configure commands,
- List URLs visited, user input (incl. example values/emails/passwords if needed),
- Describe all options and toggles enabled or changed,
- Include any files or environmental changes,
- Identify the expected and actual result at each stage,
- Ensure any reasonably skilled user can follow and hit the same issue.
required: true
- type: textarea
id: expected-behavior
attributes:
@@ -112,15 +123,25 @@ body:
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Providing clear, step-by-step instructions helps us reproduce and fix the issue faster. If we can't reproduce it, we can't fix it.
description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See the error message '...'
Example (include every detail):
1. Start with a clean Ubuntu 22.04 install.
2. Install Docker v24.0.5 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 115.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".
9. Observe that the error message "Connection refused" appears at the top right.
Please list each step carefully and include all relevant configuration, settings, and options.
validations:
required: true
- type: textarea
id: logs-screenshots
attributes:
+4
View File
@@ -17,6 +17,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Git
run: sudo apt-get update && sudo apt-get install -y git
- uses: actions/setup-node@v4
with:
node-version: 22
+113
View File
@@ -5,6 +5,119 @@ 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.12] - 2025-05-29
### Added
- 🧩 **Custom Advanced Model Parameters**: You can now add your own tailor-made advanced parameters to any model, empowering you to fine-tune behavior and unlock greater flexibility beyond just the built-in options—accelerate your experimentation.
- 🪧 **Datalab Marker API Content Extraction Support**: Seamlessly extract content from files and documents using the Datalab Marker API directly in your workflows, enabling more robust structured data extraction for RAG and document processing with just a simple engine switch in the UI.
- ⚡ **Parallelized Base Model Fetching**: Experience noticeably faster startup and model refresh times—base model data now loads in parallel, drastically shortening delays in busy or large-scale deployments.
- 🧠 **Efficient Function Loading and Caching**: Functions are now only reloaded if their content changes, preventing unnecessary duplicate loads, saving bandwidth, and boosting performance.
- 🌍 **Localization & Translation Enhancements**: Improved and expanded Simplified, Traditional Chinese, and Russian translations, providing smoother, more accurate, and context-aware experiences for global users.
### Fixed
- 💬 **Stable Message Input Box**: Fixed an issue where the message input box would shift unexpectedly (especially on mobile or with screen reader support), ensuring a smooth and reliable typing experience for every user.
- 🔊 **Reliable Read Aloud (Text-to-Speech)**: Read aloud now works seamlessly across messages, so users depending on TTS for accessibility or multitasking will experience uninterrupted and clear voice playback.
- 🖼 **Image Preview and Download Restored**: Fixed problems with image preview and downloads, ensuring frictionless creation, previewing, and downloading of images in your chats—no more interruptions in creative or documentation workflows.
- 📱 **Improved Mobile Styling for Workspace Capabilities**: Capabilities management is now readable and easy-to-use even on mobile devices, empowering admins and users to manage access quickly on the go.
- 🔁 **/api/v1/retrieval/query/collection Endpoint Reliability**: Queries to retrieval collections now return the expected results, bolstering the reliability of your knowledge workflows and citation-ready responses.
### Removed
- 🧹 **Duplicate CSS Elements**: Streamlined the UI by removing redundant CSS, reducing clutter and improving load times for a smoother visual experience.
## [0.6.11] - 2025-05-27
### Added
- 🟢 **Ollama Model Status Indicator in Model Selector**: Instantly see which Ollama models are currently loaded with a clear indicator in the model selector, helping you stay organized and optimize local model usage.
- 🗑️ **Unload Ollama Model Directly from Model Selector**: Easily release memory and resources by unloading any loaded Ollama model right in the model selector—streamline hardware management without switching pages.
- 🗣️ **User-Configurable Speech-to-Text Language Setting**: Improve transcription accuracy by letting individual users explicitly set their preferred STT language in their settings—ideal for multilingual teams and clear audio capture.
- ⚡ **Granular Audio Playback Speed Control**: Instead of just presets, you can now choose granular audio speed using a numeric input, giving you complete control over playback pace in transcriptions and media reviews.
- 📦 **GZip, Brotli, ZStd Compression Middleware**: Enjoy significantly faster page loads and reduced bandwidth usage with new server-side compression—giving users a snappier, more efficient experience.
- 🏷️ **Configurable Weight for BM25 in Hybrid Search**: Fine-tune search relevance by adjusting the weight for BM25 inside hybrid search from the UI, letting you tailor knowledge search results to your workflow.
- 🧪 **Bypass File Creation with CTRL + SHIFT + V**: When “Paste Large Text as File” is enabled, use CTRL + SHIFT + V to skip the file creation dialog and instantly upload text as a file—perfect for rapid document prep.
- 🌐 **Bypass Web Loader in Web Search**: Choose to bypass web content loading and use snippets directly in web search for faster, more reliable results when page loads are slow or blocked.
- 🚀 **Environment Variable: WEBUI_AUTH_TRUSTED_GROUPS_HEADER**: Now sync and manage user groups directly via trusted HTTP header, unlocking smoother single sign-on and identity integrations for organizations.
- 🏢 **Workspace Models Visibility Controls**: You can now hide workspace-level models from both the model selector and shared environments—keep your team focused and reduce clutter from rarely-used endpoints.
- 🛡️ **Copy Model Link**: You can now copy a direct link to any model—including those hidden from the selector—making sharing and onboarding others more seamless.
- 🔗 **Load Function Directly from URL**: Simplify custom function management—just paste any GitHub function URL into Open WebUI and import new functions in seconds.
- ⚙️ **Custom Name/Description for External Tool Servers**: Personalize and clarify external tool servers by assigning custom names and descriptions, making it easier to manage integrations in large-scale workspaces.
- 🌍 **Custom OpenAPI JSON URL Support for Tool Servers**: Supports specifying any custom OpenAPI JSON URL, unlocking more flexible integration with any backend for tool calls.
- 📊 **Source Field Now Displays in Non-Streaming Responses with Attachments**: When files or knowledge are attached, the "source" field now appears for all responses, even in non-streaming mode—enabling improved citation workflow.
- 🎛 **Pinned Chats**: Reduced payload size on pinned chat requests—leading to faster load times and less data usage, especially on busy warehouses.
- 🛠 **Import/Export Default Prompt Suggestions**: Enjoy one-click import/export of prompt suggestions, making it much easier to share, reuse, and manage best practices across teams or deployments.
- 🍰 **Banners Now Sortable from Admin Settings**: Quickly re-order or prioritize banners, letting you highlight the most critical info for your team.
- 🛠 **Advanced Chat Parameters—Clearer Ollama Support Labels**: Parameters and advanced settings now explicitly indicate if they are Ollama-specific, reducing confusion and improving setup accuracy.
- 🤏 **Scroll Bar Thumb Improved for Better Visibility**: Enhanced scrollbar styling makes navigation more accessible and visually intuitive.
- 🗄️ **Modal Redesign for Archived and User Chat Listings**: Clean, modern modal interface for browsing archived and user-specific chats makes locating conversations faster and more pleasant.
- 📝 **Add/Edit Memory Modal UX**: Memory modals are now larger and have resizable input fields, supporting easier editing of long or complex memory content.
- 🏆 **Translation & Localization Enhancements**: Major upgrades to Chinese (Simplified & Traditional), Korean, Russian, German, Danish, Finnish—not just fixing typos, but consistency, tone, and terminology for a more natural native-language experience.
- ⚡ **General Backend Stability & Security Enhancements**: Various backend refinements ensure a more resilient, reliable, and secure platform for smoother operation and peace of mind.
### Fixed
- 🖼️ **Image Generation with Allowed File Extensions Now Works Reliably**: Ensure seamless image generation even when strict file extension rules are set—no more blocked creative workflows due to technical hiccups.
- 🗂 **Remove Leading Dot for File Extension Check**: Fixed an issue where file validation failed because of a leading dot, making file uploads and knowledge management more robust.
- 🏷️ **Correct Local/External Model Classification**: The platform now accurately distinguishes between local and external models—preventing local models from showing up as external (and vice versa)—ensuring seamless setup, clarity, and management of your AI model endpoints.
- 📄 **External Document Loader Now Functions as Intended**: External document loaders are reliably invoked, ensuring smoother knowledge ingestion from external sources—expanding your RAG and knowledge workflows.
- 🎯 **Correct Handling of Toggle Filters**: Toggle filters are now robustly managed, preventing accidental auto-activation and ensuring user preferences are always respected.
- 🗃 **S3 Tagging Character Restrictions Fixed**: Tags for files in S3 now automatically meet Amazon’s allowed character set, avoiding upload errors and ensuring cross-cloud compatibility.
- 🛡️ **Authentication Now Uses Password Hash When Duplicate Emails Exist**: Ensures account security and prevents access issues if duplicate emails are present in your system.
### Changed
- 🧩 **Admin Settings: OAuth Redirects Now Use WEBUI_URL**: The OAuth redirect URL is now based on the explicitly set WEBUI_URL, ensuring single sign-on and identity provider integrations always send users to the correct frontend.
### Removed
- 💡 **Duplicate/Typo Component Removals**: Obsolete components have been cleaned up, reducing confusion and improving overall code quality for the team.
- 🚫 **Streaming Upsert in Pinecone Removed**: Removed streaming upsert references for better compatibility and future-proofing with latest Pinecone SDK updates.
## [0.6.10] - 2025-05-19
### Added
- 🧩 **Experimental Azure OpenAI Support**: Instantly connect to Azure OpenAI endpoints by simply pasting your Azure OpenAI URL into the model connections—bringing flexible, enterprise-grade AI integration directly to your workflow.
- 💧 **Watermark AI Responses**: Easily add a visible watermark to AI-generated responses for clear content provenance and compliance with EU AI regulations—perfect for regulated environments and transparent communication.
- 🔍 **Enhanced Search Experience with Dedicated Modal**: Enjoy a modern, powerful search UI in a dedicated modal (open with Ctrl/Cmd + K) accessible from anywhere—quickly find chats, models, or content and boost your productivity.
- 🔲 **"Toggle" Filter Type for Chat Input**: Add interactive toggle filters (e.g. Web Search, Image, Code Interpreter) right into the chat input—giving you one-click control to activate features and simplifying the chat configuration process.
- 🧰 **Granular Model Capabilities Editor**: Define detailed capabilities and feature access for each AI model directly from the model editor—enabling tailored behavior, modular control, and a more customized AI environment for every team or use case.
- 🌐 **Flexible Local and External Connection Support**: Now you can designate any AI connection—whether OpenAI, Ollama, or others—as local or external, enabling seamless setup for on-premise, self-hosted, or cloud configurations and giving you maximum control and flexibility.
- 🗂️ **Allowed File Extensions for RAG**: Gain full control over your Retrieval-Augmented Generation (RAG) workflows by specifying exactly which file extensions are permitted for upload, improving security and relevance of indexed documents.
- 🔊 **Enhanced Audio Transcription Logic**: Experience smoother, more reliable audio transcription—very long audio files are now automatically split and processed in segments, preventing errors and ensuring even challenging files are transcribed seamlessly, all part of a broader stability upgrade for robust media workflows.
- 🦾 **External Document Loader Support**: Enhance knowledge base building by integrating documents using external loaders from a wide range of data sources, expanding what your AI can read and process.
- 📝 **Preview Button for Code Artifacts**: Instantly jump from an HTML code block to its associated artifacts page with the click of a new preview button—speeding up review and streamlining analysis.
- 🦻 **Screen Reader Support for Response Messages**: All chat responses are now fully compatible with screen readers, making the platform more inclusive and accessible for everyone.
- 🧑‍💼 **Customizable Pending User Overlay**: You can now tailor the overlay title and content shown to pending users, ensuring onboarding messaging is perfectly aligned with your organization’s tone and requirements.
- 🔐 **Option to Disable LDAP Certificate Validation**: You can now disable LDAP certificate validation for maximum flexibility in diverse IT environments—making integrations and troubleshooting much easier.
- 🎯 **Workspace Search by Username or Email**: Easily search across workspace pages using any username or email address, streamlining user and resource management.
- 🎨 **High Contrast & Dark Mode Enhancements**: Further improved placeholder, input, suggestion, toast, and model selector contrasts—including a dedicated placeholder dark mode—for more comfortable viewing in all lighting conditions.
- 🛡️ **Refined Security for Pipelines & Model Uploads**: Strengthened safeguards against path traversal vulnerabilities during uploads—ensuring your platform’s document and model management remains secure.
- 🌏 **Major Localization Upgrades**: Comprehensive translation updates and improvements across Korean, Bulgarian, Catalan, Japanese, Italian, Traditional Chinese, and Spanish—including more accurate AI terminology for clarity; your experience is now more natural, inclusive, and professional everywhere.
- 🦾 **General Backend Stability & Security**: Multiple backend improvements (including file upload, command navigation, and logging refactorings) deliver increased resilience, better error handling, and a more robust platform for all users.
### Fixed
- ✅ **Evaluation Feedback Endpoint Reliability**: Addressed issues with feedback submission endpoints to ensure seamless user feedback collection on model responses.
- 🫰 **Model List State Fixes**: Resolved issues where model status toggles in the workspace page might inadvertently switch or confuse state, making the management of active/inactive models more dependable.
- ✍️ **Admin Signup Logic**: Admin self-signup experience and validation flow is smoother and more robust.
- 🔁 **Signout Redirect Flow Improved**: Logging out now redirects more reliably, reducing confusion and making session management seamless.
## [0.6.9] - 2025-05-10
### Added
- 📝 **Edit Attached Images/Files in Messages**: You can now easily edit your sent messages by removing attached files—streamlining document management, correcting mistakes on the fly, and keeping your chats clutter-free.
- 🚨 **Clear Alerts for Private Task Models**: When interacting with private task models, the UI now clearly alerts you—making it easier to understand resource availability and access, reducing confusion during workflow setup.
### Fixed
- 🛡️ **Confirm Dialog Focus Trap Reliability**: The focus now stays correctly within confirmation dialogs, ensuring keyboard navigation and accessibility is seamless and preventing accidental operations—especially helpful during critical or rapid workflows.
- 💬 **Temporary Chat Admin Controls & Session Cleanliness**: Admins are now able to properly enable temporary chat mode without errors, and previous session prompts or tool selections no longer carry over—delivering a fresh, predictable, and consistent temporary chat experience every time.
- 🤖 **External Reranker Integration Functionality Restored**: External reranker integrations now work correctly, allowing you to fully leverage advanced ranking services for sharper, more relevant search results in your RAG and knowledge base workflows.
## [0.6.8] - 2025-05-10
### Added
+3
View File
@@ -26,6 +26,9 @@ ARG BUILD_HASH
WORKDIR /app
# to store git revision in build
RUN apk add --no-cache git
COPY package.json package-lock.json ./
RUN npm ci
+11 -1
View File
@@ -73,7 +73,17 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
</a>
</td>
<td>
Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
N8N • Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
</td>
</tr>
<tr>
<td>
<a href="https://warp.dev/open-webui" target="_blank">
<img src="https://docs.openwebui.com/sponsors/logos/warp.png" alt="n8n" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
</a>
</td>
<td>
Wrap • The intelligent terminal for developers
</td>
</tr>
</table>
+124
View File
@@ -989,6 +989,26 @@ DEFAULT_USER_ROLE = PersistentConfig(
os.getenv("DEFAULT_USER_ROLE", "pending"),
)
PENDING_USER_OVERLAY_TITLE = PersistentConfig(
"PENDING_USER_OVERLAY_TITLE",
"ui.pending_user_overlay_title",
os.environ.get("PENDING_USER_OVERLAY_TITLE", ""),
)
PENDING_USER_OVERLAY_CONTENT = PersistentConfig(
"PENDING_USER_OVERLAY_CONTENT",
"ui.pending_user_overlay_content",
os.environ.get("PENDING_USER_OVERLAY_CONTENT", ""),
)
RESPONSE_WATERMARK = PersistentConfig(
"RESPONSE_WATERMARK",
"ui.watermark",
os.environ.get("RESPONSE_WATERMARK", ""),
)
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS", "False").lower()
== "true"
@@ -1731,6 +1751,9 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
ENABLE_QDRANT_MULTITENANCY_MODE = (
os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
)
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -1825,6 +1848,73 @@ CONTENT_EXTRACTION_ENGINE = PersistentConfig(
os.environ.get("CONTENT_EXTRACTION_ENGINE", "").lower(),
)
DATALAB_MARKER_API_KEY = PersistentConfig(
"DATALAB_MARKER_API_KEY",
"rag.datalab_marker_api_key",
os.environ.get("DATALAB_MARKER_API_KEY", ""),
)
DATALAB_MARKER_LANGS = PersistentConfig(
"DATALAB_MARKER_LANGS",
"rag.datalab_marker_langs",
os.environ.get("DATALAB_MARKER_LANGS", ""),
)
DATALAB_MARKER_USE_LLM = PersistentConfig(
"DATALAB_MARKER_USE_LLM",
"rag.DATALAB_MARKER_USE_LLM",
os.environ.get("DATALAB_MARKER_USE_LLM", "false").lower() == "true",
)
DATALAB_MARKER_SKIP_CACHE = PersistentConfig(
"DATALAB_MARKER_SKIP_CACHE",
"rag.datalab_marker_skip_cache",
os.environ.get("DATALAB_MARKER_SKIP_CACHE", "false").lower() == "true",
)
DATALAB_MARKER_FORCE_OCR = PersistentConfig(
"DATALAB_MARKER_FORCE_OCR",
"rag.datalab_marker_force_ocr",
os.environ.get("DATALAB_MARKER_FORCE_OCR", "false").lower() == "true",
)
DATALAB_MARKER_PAGINATE = PersistentConfig(
"DATALAB_MARKER_PAGINATE",
"rag.datalab_marker_paginate",
os.environ.get("DATALAB_MARKER_PAGINATE", "false").lower() == "true",
)
DATALAB_MARKER_STRIP_EXISTING_OCR = PersistentConfig(
"DATALAB_MARKER_STRIP_EXISTING_OCR",
"rag.datalab_marker_strip_existing_ocr",
os.environ.get("DATALAB_MARKER_STRIP_EXISTING_OCR", "false").lower() == "true",
)
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig(
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION",
"rag.datalab_marker_disable_image_extraction",
os.environ.get("DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", "false").lower()
== "true",
)
DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig(
"DATALAB_MARKER_OUTPUT_FORMAT",
"rag.datalab_marker_output_format",
os.environ.get("DATALAB_MARKER_OUTPUT_FORMAT", "markdown"),
)
EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig(
"EXTERNAL_DOCUMENT_LOADER_URL",
"rag.external_document_loader_url",
os.environ.get("EXTERNAL_DOCUMENT_LOADER_URL", ""),
)
EXTERNAL_DOCUMENT_LOADER_API_KEY = PersistentConfig(
"EXTERNAL_DOCUMENT_LOADER_API_KEY",
"rag.external_document_loader_api_key",
os.environ.get("EXTERNAL_DOCUMENT_LOADER_API_KEY", ""),
)
TIKA_SERVER_URL = PersistentConfig(
"TIKA_SERVER_URL",
"rag.tika_server_url",
@@ -1849,6 +1939,12 @@ DOCLING_OCR_LANG = PersistentConfig(
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
)
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
"DOCLING_DO_PICTURE_DESCRIPTION",
"rag.docling_do_picture_description",
os.getenv("DOCLING_DO_PICTURE_DESCRIPTION", "False").lower() == "true",
)
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
@@ -1887,6 +1983,11 @@ RAG_RELEVANCE_THRESHOLD = PersistentConfig(
"rag.relevance_threshold",
float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")),
)
RAG_HYBRID_BM25_WEIGHT = PersistentConfig(
"RAG_HYBRID_BM25_WEIGHT",
"rag.hybrid_bm25_weight",
float(os.environ.get("RAG_HYBRID_BM25_WEIGHT", "0.5")),
)
ENABLE_RAG_HYBRID_SEARCH = PersistentConfig(
"ENABLE_RAG_HYBRID_SEARCH",
@@ -1920,6 +2021,16 @@ RAG_FILE_MAX_SIZE = PersistentConfig(
),
)
RAG_ALLOWED_FILE_EXTENSIONS = PersistentConfig(
"RAG_ALLOWED_FILE_EXTENSIONS",
"rag.file.allowed_extensions",
[
ext.strip()
for ext in os.environ.get("RAG_ALLOWED_FILE_EXTENSIONS", "").split(",")
if ext.strip()
],
)
RAG_EMBEDDING_ENGINE = PersistentConfig(
"RAG_EMBEDDING_ENGINE",
"rag.embedding_engine",
@@ -2126,6 +2237,12 @@ BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
)
BYPASS_WEB_SEARCH_WEB_LOADER = PersistentConfig(
"BYPASS_WEB_SEARCH_WEB_LOADER",
"rag.web.search.bypass_web_loader",
os.getenv("BYPASS_WEB_SEARCH_WEB_LOADER", "False").lower() == "true",
)
WEB_SEARCH_RESULT_COUNT = PersistentConfig(
"WEB_SEARCH_RESULT_COUNT",
"rag.web.search.result_count",
@@ -2151,6 +2268,7 @@ WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
)
WEB_LOADER_ENGINE = PersistentConfig(
"WEB_LOADER_ENGINE",
"rag.web.loader.engine",
@@ -2839,6 +2957,12 @@ LDAP_CA_CERT_FILE = PersistentConfig(
os.environ.get("LDAP_CA_CERT_FILE", ""),
)
LDAP_VALIDATE_CERT = PersistentConfig(
"LDAP_VALIDATE_CERT",
"ldap.server.validate_cert",
os.environ.get("LDAP_VALIDATE_CERT", "True").lower() == "true",
)
LDAP_CIPHERS = PersistentConfig(
"LDAP_CIPHERS", "ldap.server.ciphers", os.environ.get("LDAP_CIPHERS", "ALL")
)
+4
View File
@@ -349,6 +349,10 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None)
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_GROUPS_HEADER", None
)
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
+5 -7
View File
@@ -28,7 +28,10 @@ from open_webui.socket.main import (
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import has_access
@@ -53,12 +56,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_function_module_by_id(request: Request, pipe_id: str):
# Check if function is already loaded
if pipe_id not in request.app.state.FUNCTIONS:
function_module, _, _ = load_function_module_by_id(pipe_id)
request.app.state.FUNCTIONS[pipe_id] = function_module
else:
function_module = request.app.state.FUNCTIONS[pipe_id]
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(pipe_id)
+2 -2
View File
@@ -43,7 +43,7 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url):
db = connect(db_url, unquote_password=True)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
@@ -51,7 +51,7 @@ def register_connection(db_url):
log.info("Connected to PostgreSQL database")
# Get the connection details
connection = parse(db_url, unquote_password=True)
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
+62 -5
View File
@@ -40,6 +40,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette_compress import CompressMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
@@ -196,7 +198,11 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_BATCH_SIZE,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_RELEVANCE_THRESHOLD,
RAG_HYBRID_BM25_WEIGHT,
RAG_ALLOWED_FILE_EXTENSIONS,
RAG_FILE_MAX_COUNT,
RAG_FILE_MAX_SIZE,
RAG_OPENAI_API_BASE_URL,
@@ -206,15 +212,25 @@ from open_webui.config import (
CHUNK_OVERLAP,
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
DATALAB_MARKER_LANGS,
DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_OUTPUT_FORMAT,
DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG,
DOCLING_DO_PICTURE_DESCRIPTION,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
@@ -224,6 +240,7 @@ from open_webui.config import (
ENABLE_WEB_SEARCH,
WEB_SEARCH_ENGINE,
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
BYPASS_WEB_SEARCH_WEB_LOADER,
WEB_SEARCH_RESULT_COUNT,
WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV,
@@ -291,6 +308,8 @@ from open_webui.config import (
ENABLE_EVALUATION_ARENA_MODELS,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
PENDING_USER_OVERLAY_CONTENT,
PENDING_USER_OVERLAY_TITLE,
DEFAULT_PROMPT_SUGGESTIONS,
DEFAULT_MODELS,
DEFAULT_ARENA_MODEL,
@@ -317,6 +336,7 @@ from open_webui.config import (
LDAP_APP_PASSWORD,
LDAP_USE_TLS,
LDAP_CA_CERT_FILE,
LDAP_VALIDATE_CERT,
LDAP_CIPHERS,
# Misc
ENV,
@@ -327,6 +347,7 @@ from open_webui.config import (
DEFAULT_LOCALE,
OAUTH_PROVIDERS,
WEBUI_URL,
RESPONSE_WATERMARK,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
@@ -373,6 +394,7 @@ from open_webui.env import (
OFFLINE_MODE,
ENABLE_OTEL,
EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -573,6 +595,11 @@ app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT
app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE
app.state.config.RESPONSE_WATERMARK = RESPONSE_WATERMARK
app.state.config.USER_PERMISSIONS = USER_PERMISSIONS
app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.BANNERS = WEBUI_BANNERS
@@ -609,6 +636,7 @@ app.state.config.LDAP_SEARCH_BASE = LDAP_SEARCH_BASE
app.state.config.LDAP_SEARCH_FILTERS = LDAP_SEARCH_FILTERS
app.state.config.LDAP_USE_TLS = LDAP_USE_TLS
app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE
app.state.config.LDAP_VALIDATE_CERT = LDAP_VALIDATE_CERT
app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
@@ -618,8 +646,12 @@ app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL
app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL
app.state.USER_COUNT = None
app.state.TOOLS = {}
app.state.TOOL_CONTENTS = {}
app.state.FUNCTIONS = {}
app.state.FUNCTION_CONTENTS = {}
########################################
#
@@ -631,6 +663,8 @@ app.state.FUNCTIONS = {}
app.state.config.TOP_K = RAG_TOP_K
app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER
app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
app.state.config.HYBRID_BM25_WEIGHT = RAG_HYBRID_BM25_WEIGHT
app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS
app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
@@ -641,10 +675,24 @@ app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH
app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERIFICATION
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY
app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS
app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE
app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR
app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE
app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTING_OCR
app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM
app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT
app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
@@ -688,6 +736,7 @@ app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
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
@@ -940,6 +989,7 @@ class RedirectMiddleware(BaseHTTPMiddleware):
# Add the middleware to the app
app.add_middleware(CompressMiddleware)
app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@@ -1167,11 +1217,12 @@ async def chat_completion(
"chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None),
"filter_ids": form_data.pop("filter_ids", []),
"tool_ids": form_data.get("tool_ids", None),
"tool_servers": form_data.pop("tool_servers", None),
"files": form_data.get("files", None),
"features": form_data.get("features", None),
"variables": form_data.get("variables", None),
"features": form_data.get("features", {}),
"variables": form_data.get("variables", {}),
"model": model,
"direct": model_item.get("direct", False),
**(
@@ -1395,6 +1446,11 @@ async def get_app_config(request: Request):
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
},
"ui": {
"pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE,
"pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
"response_watermark": app.state.config.RESPONSE_WATERMARK,
},
"license_metadata": app.state.LICENSE_METADATA,
**(
{
@@ -1446,7 +1502,8 @@ async def get_app_latest_release_version(user=Depends(get_verified_user)):
timeout = aiohttp.ClientTimeout(total=1)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
"https://api.github.com/repos/open-webui/open-webui/releases/latest"
"https://api.github.com/repos/open-webui/open-webui/releases/latest",
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
+8 -4
View File
@@ -129,12 +129,16 @@ class AuthsTable:
def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email)
if not user:
return None
try:
with get_db() as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth:
if verify_password(password, auth.password):
user = Users.get_user_by_id(auth.id)
return user
else:
return None
@@ -155,8 +159,8 @@ class AuthsTable:
except Exception:
return False
def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]:
log.info(f"authenticate_user_by_trusted_header: {email}")
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
log.info(f"authenticate_user_by_email: {email}")
try:
with get_db() as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
+53 -10
View File
@@ -377,22 +377,47 @@ class ChatTable:
return False
def get_archived_chat_list_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
) -> list[ChatModel]:
with get_db() as db:
all_chats = (
db.query(Chat)
.filter_by(user_id=user_id, archived=True)
.order_by(Chat.updated_at.desc())
# .limit(limit).offset(skip)
.all()
)
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_list_by_user_id(
self,
user_id: str,
include_archived: bool = False,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
) -> list[ChatModel]:
@@ -401,7 +426,23 @@ class ChatTable:
if not include_archived:
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc())
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
@@ -542,7 +583,9 @@ class ChatTable:
search_text = search_text.lower().strip()
if not search_text:
return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit)
return self.get_chat_list_by_user_id(
user_id, include_archived, filter={}, skip=skip, limit=limit
)
search_text_words = search_text.split(" ")
+48
View File
@@ -108,6 +108,54 @@ class FunctionsTable:
log.exception(f"Error creating a new function: {e}")
return None
def sync_functions(
self, user_id: str, functions: list[FunctionModel]
) -> list[FunctionModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
# Get existing functions
existing_functions = db.query(Function).all()
existing_ids = {func.id for func in existing_functions}
# Prepare a set of new function IDs
new_function_ids = {func.id for func in functions}
# Update or insert functions
for func in functions:
if func.id in existing_ids:
db.query(Function).filter_by(id=func.id).update(
{
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
else:
new_func = Function(
**{
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_func)
# Remove functions that are no longer present
for func in existing_functions:
if func.id not in new_function_ids:
db.delete(func)
db.commit()
return [
FunctionModel.model_validate(func)
for func in db.query(Function).all()
]
except Exception as e:
log.exception(f"Error syncing functions for user {user_id}: {e}")
return []
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
try:
with get_db() as db:
+38
View File
@@ -207,5 +207,43 @@ class GroupTable:
except Exception:
return False
def sync_user_groups_by_group_names(
self, user_id: str, group_names: list[str]
) -> bool:
with get_db() as db:
try:
groups = db.query(Group).filter(Group.name.in_(group_names)).all()
group_ids = [group.id for group in groups]
# Remove user from groups not in the new list
existing_groups = self.get_groups_by_member_id(user_id)
for group in existing_groups:
if group.id not in group_ids:
group.user_ids.remove(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
# Add user to new groups
for group in groups:
if user_id not in group.user_ids:
group.user_ids.append(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
return True
except Exception as e:
log.exception(e)
return False
Groups = GroupTable()
@@ -0,0 +1,251 @@
import os
import time
import requests
import logging
import json
from typing import List, Optional
from langchain_core.documents import Document
from fastapi import HTTPException, status
log = logging.getLogger(__name__)
class DatalabMarkerLoader:
def __init__(
self,
file_path: str,
api_key: str,
langs: Optional[str] = None,
use_llm: bool = False,
skip_cache: bool = False,
force_ocr: bool = False,
paginate: bool = False,
strip_existing_ocr: bool = False,
disable_image_extraction: bool = False,
output_format: str = None,
):
self.file_path = file_path
self.api_key = api_key
self.langs = langs
self.use_llm = use_llm
self.skip_cache = skip_cache
self.force_ocr = force_ocr
self.paginate = paginate
self.strip_existing_ocr = strip_existing_ocr
self.disable_image_extraction = disable_image_extraction
self.output_format = output_format
def _get_mime_type(self, filename: str) -> str:
ext = filename.rsplit(".", 1)[-1].lower()
mime_map = {
"pdf": "application/pdf",
"xls": "application/vnd.ms-excel",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ods": "application/vnd.oasis.opendocument.spreadsheet",
"doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"odt": "application/vnd.oasis.opendocument.text",
"ppt": "application/vnd.ms-powerpoint",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"odp": "application/vnd.oasis.opendocument.presentation",
"html": "text/html",
"epub": "application/epub+zip",
"png": "image/png",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
"tiff": "image/tiff",
}
return mime_map.get(ext, "application/octet-stream")
def check_marker_request_status(self, request_id: str) -> dict:
url = f"https://www.datalab.to/api/v1/marker/{request_id}"
headers = {"X-Api-Key": self.api_key}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
result = response.json()
log.info(f"Marker API status check for request {request_id}: {result}")
return result
except requests.HTTPError as e:
log.error(f"Error checking Marker request status: {e}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to check Marker request: {e}",
)
except ValueError as e:
log.error(f"Invalid JSON checking Marker request: {e}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON: {e}"
)
def load(self) -> List[Document]:
url = "https://www.datalab.to/api/v1/marker"
filename = os.path.basename(self.file_path)
mime_type = self._get_mime_type(filename)
headers = {"X-Api-Key": self.api_key}
form_data = {
"langs": self.langs,
"use_llm": str(self.use_llm).lower(),
"skip_cache": str(self.skip_cache).lower(),
"force_ocr": str(self.force_ocr).lower(),
"paginate": str(self.paginate).lower(),
"strip_existing_ocr": str(self.strip_existing_ocr).lower(),
"disable_image_extraction": str(self.disable_image_extraction).lower(),
"output_format": self.output_format,
}
log.info(
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
)
try:
with open(self.file_path, "rb") as f:
files = {"file": (filename, f, mime_type)}
response = requests.post(
url, data=form_data, files=files, headers=headers
)
response.raise_for_status()
result = response.json()
except FileNotFoundError:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
)
except requests.HTTPError as e:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Datalab Marker request failed: {e}",
)
except ValueError as e:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON response: {e}"
)
except Exception as e:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
if not result.get("success"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Datalab Marker request failed: {result.get('error', 'Unknown error')}",
)
check_url = result.get("request_check_url")
request_id = result.get("request_id")
if not check_url:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned."
)
for _ in range(300): # Up to 10 minutes
time.sleep(2)
try:
poll_response = requests.get(check_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
except (requests.HTTPError, ValueError) as e:
raw_body = poll_response.text
log.error(f"Polling error: {e}, response body: {raw_body}")
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}"
)
status_val = poll_result.get("status")
success_val = poll_result.get("success")
if status_val == "complete":
summary = {
k: poll_result.get(k)
for k in (
"status",
"output_format",
"success",
"error",
"page_count",
"total_cost",
)
}
log.info(
f"Marker processing completed successfully: {json.dumps(summary, indent=2)}"
)
break
if status_val == "failed" or success_val is False:
log.error(
f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}"
)
error_msg = (
poll_result.get("error")
or "Marker returned failure without error message"
)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Marker processing failed: {error_msg}",
)
else:
raise HTTPException(
status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
)
if not poll_result.get("success", False):
error_msg = poll_result.get("error") or "Unknown processing error"
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Final processing failed: {error_msg}",
)
content_key = self.output_format.lower()
raw_content = poll_result.get(content_key)
if content_key == "json":
full_text = json.dumps(raw_content, indent=2)
elif content_key in {"markdown", "html"}:
full_text = str(raw_content).strip()
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported output format: {self.output_format}",
)
if not full_text:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Datalab Marker returned empty content",
)
marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output")
os.makedirs(marker_output_dir, exist_ok=True)
file_ext_map = {"markdown": "md", "json": "json", "html": "html"}
file_ext = file_ext_map.get(content_key, "txt")
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
output_path = os.path.join(marker_output_dir, output_filename)
try:
with open(output_path, "w", encoding="utf-8") as f:
f.write(full_text)
log.info(f"Saved Marker output to: {output_path}")
except Exception as e:
log.warning(f"Failed to write marker output to disk: {e}")
metadata = {
"source": filename,
"output_format": poll_result.get("output_format", self.output_format),
"page_count": poll_result.get("page_count", 0),
"processed_with_llm": self.use_llm,
"request_id": request_id or "",
}
images = poll_result.get("images", {})
if images:
metadata["image_count"] = len(images)
metadata["images"] = json.dumps(list(images.keys()))
for k, v in metadata.items():
if isinstance(v, (dict, list)):
metadata[k] = json.dumps(v)
elif v is None:
metadata[k] = ""
return [Document(page_content=full_text, metadata=metadata)]
@@ -0,0 +1,58 @@
import requests
import logging
from typing import Iterator, List, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalDocumentLoader(BaseLoader):
def __init__(
self,
file_path,
url: str,
api_key: str,
mime_type=None,
**kwargs,
) -> None:
self.url = url
self.api_key = api_key
self.file_path = file_path
self.mime_type = mime_type
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
data = f.read()
headers = {}
if self.mime_type is not None:
headers["Content-Type"] = self.mime_type
if self.api_key is not None:
headers["Authorization"] = f"Bearer {self.api_key}"
url = self.url
if url.endswith("/"):
url = url[:-1]
r = requests.put(f"{url}/process", data=data, headers=headers)
if r.ok:
res = r.json()
if res:
return [
Document(
page_content=res.get("page_content"),
metadata=res.get("metadata"),
)
]
else:
raise Exception("Error loading document: No content returned")
else:
raise Exception(f"Error loading document: {r.status_code} {r.text}")
@@ -10,7 +10,7 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalLoader(BaseLoader):
class ExternalWebLoader(BaseLoader):
def __init__(
self,
web_paths: Union[str, List[str]],
@@ -32,7 +32,7 @@ class ExternalLoader(BaseLoader):
response = requests.post(
self.external_url,
headers={
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader",
"Authorization": f"Bearer {self.external_api_key}",
},
json={
+91 -13
View File
@@ -21,7 +21,11 @@ from langchain_community.document_loaders import (
)
from langchain_core.documents import Document
from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
@@ -126,14 +130,12 @@ class TikaLoader:
class DoclingLoader:
def __init__(
self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None
):
def __init__(self, url, file_path=None, mime_type=None, params=None):
self.url = url.rstrip("/")
self.file_path = file_path
self.mime_type = mime_type
self.ocr_engine = ocr_engine
self.ocr_lang = ocr_lang
self.params = params or {}
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
@@ -150,11 +152,19 @@ class DoclingLoader:
"table_mode": "accurate",
}
if self.ocr_engine and self.ocr_lang:
params["ocr_engine"] = self.ocr_engine
params["ocr_lang"] = [
lang.strip() for lang in self.ocr_lang.split(",") if lang.strip()
]
if self.params:
if self.params.get("do_picture_classification"):
params["do_picture_classification"] = self.params.get(
"do_picture_classification"
)
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
params["ocr_engine"] = self.params.get("ocr_engine")
params["ocr_lang"] = [
lang.strip()
for lang in self.params.get("ocr_lang").split(",")
if lang.strip()
]
endpoint = f"{self.url}/v1alpha/convert/file"
r = requests.post(endpoint, files=files, data=params)
@@ -207,7 +217,18 @@ class Loader:
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
file_ext = filename.split(".")[-1].lower()
if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
if (
self.engine == "external"
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL")
and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY")
):
loader = ExternalDocumentLoader(
file_path=file_path,
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
mime_type=file_content_type,
)
elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
if self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
else:
@@ -217,6 +238,49 @@ class Loader:
mime_type=file_content_type,
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
)
elif (
self.engine == "datalab_marker"
and self.kwargs.get("DATALAB_MARKER_API_KEY")
and file_ext
in [
"pdf",
"xls",
"xlsx",
"ods",
"doc",
"docx",
"odt",
"ppt",
"pptx",
"odp",
"html",
"epub",
"png",
"jpeg",
"jpg",
"webp",
"gif",
"tiff",
]
):
loader = DatalabMarkerLoader(
file_path=file_path,
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False),
skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False),
force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False),
paginate=self.kwargs.get("DATALAB_MARKER_PAGINATE", False),
strip_existing_ocr=self.kwargs.get(
"DATALAB_MARKER_STRIP_EXISTING_OCR", False
),
disable_image_extraction=self.kwargs.get(
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
),
output_format=self.kwargs.get(
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
),
)
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
if self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
@@ -225,8 +289,13 @@ class Loader:
url=self.kwargs.get("DOCLING_SERVER_URL"),
file_path=file_path,
mime_type=file_content_type,
ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"),
ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"),
params={
"ocr_engine": self.kwargs.get("DOCLING_OCR_ENGINE"),
"ocr_lang": self.kwargs.get("DOCLING_OCR_LANG"),
"do_picture_classification": self.kwargs.get(
"DOCLING_DO_PICTURE_DESCRIPTION"
),
},
)
elif (
self.engine == "document_intelligence"
@@ -258,6 +327,15 @@ class Loader:
loader = MistralLoader(
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
)
elif (
self.engine == "external"
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
and file_ext
in ["pdf"] # Mistral OCR currently only supports PDF and images
):
loader = MistralLoader(
api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
)
else:
if file_ext == "pdf":
loader = PyPDFLoader(
+476 -68
View File
@@ -1,8 +1,12 @@
import requests
import aiohttp
import asyncio
import logging
import os
import sys
import time
from typing import List, Dict, Any
from contextlib import asynccontextmanager
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
@@ -14,18 +18,29 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class MistralLoader:
"""
Enhanced Mistral OCR loader with both sync and async support.
Loads documents by processing them through the Mistral OCR API.
"""
BASE_API_URL = "https://api.mistral.ai/v1"
def __init__(self, api_key: str, file_path: str):
def __init__(
self,
api_key: str,
file_path: str,
timeout: int = 300, # 5 minutes default
max_retries: int = 3,
enable_debug_logging: bool = False,
):
"""
Initializes the loader.
Initializes the loader with enhanced features.
Args:
api_key: Your Mistral API key.
file_path: The local path to the PDF file to process.
timeout: Request timeout in seconds.
max_retries: Maximum number of retry attempts.
enable_debug_logging: Enable detailed debug logs.
"""
if not api_key:
raise ValueError("API key cannot be empty.")
@@ -34,7 +49,23 @@ class MistralLoader:
self.api_key = api_key
self.file_path = file_path
self.headers = {"Authorization": f"Bearer {self.api_key}"}
self.timeout = timeout
self.max_retries = max_retries
self.debug = enable_debug_logging
# Pre-compute file info for performance
self.file_name = os.path.basename(file_path)
self.file_size = os.path.getsize(file_path)
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"User-Agent": "OpenWebUI-MistralLoader/2.0",
}
def _debug_log(self, message: str, *args) -> None:
"""Conditional debug logging for performance."""
if self.debug:
log.debug(message, *args)
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
"""Checks response status and returns JSON content."""
@@ -54,24 +85,89 @@ class MistralLoader:
log.error(f"JSON decode error: {json_err} - Response: {response.text}")
raise # Re-raise after logging
async def _handle_response_async(
self, response: aiohttp.ClientResponse
) -> Dict[str, Any]:
"""Async version of response handling with better error info."""
try:
response.raise_for_status()
# Check content type
content_type = response.headers.get("content-type", "")
if "application/json" not in content_type:
if response.status == 204:
return {}
text = await response.text()
raise ValueError(
f"Unexpected content type: {content_type}, body: {text[:200]}..."
)
return await response.json()
except aiohttp.ClientResponseError as e:
error_text = await response.text() if response else "No response"
log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}")
raise
except aiohttp.ClientError as e:
log.error(f"Client error: {e}")
raise
except Exception as e:
log.error(f"Unexpected error processing response: {e}")
raise
def _retry_request_sync(self, request_func, *args, **kwargs):
"""Synchronous retry logic with exponential backoff."""
for attempt in range(self.max_retries):
try:
return request_func(*args, **kwargs)
except (requests.exceptions.RequestException, Exception) as e:
if attempt == self.max_retries - 1:
raise
wait_time = (2**attempt) + 0.5
log.warning(
f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..."
)
time.sleep(wait_time)
async def _retry_request_async(self, request_func, *args, **kwargs):
"""Async retry logic with exponential backoff."""
for attempt in range(self.max_retries):
try:
return await request_func(*args, **kwargs)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == self.max_retries - 1:
raise
wait_time = (2**attempt) + 0.5
log.warning(
f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..."
)
await asyncio.sleep(wait_time)
def _upload_file(self) -> str:
"""Uploads the file to Mistral for OCR processing."""
"""Uploads the file to Mistral for OCR processing (sync version)."""
log.info("Uploading file to Mistral API")
url = f"{self.BASE_API_URL}/files"
file_name = os.path.basename(self.file_path)
try:
def upload_request():
with open(self.file_path, "rb") as f:
files = {"file": (file_name, f, "application/pdf")}
data = {"purpose": "ocr"}
upload_headers = self.headers.copy() # Avoid modifying self.headers
response = requests.post(
url, headers=upload_headers, files=files, data=data
url,
headers=self.headers,
files=files,
data=data,
timeout=self.timeout,
)
response_data = self._handle_response(response)
return self._handle_response(response)
try:
response_data = self._retry_request_sync(upload_request)
file_id = response_data.get("id")
if not file_id:
raise ValueError("File ID not found in upload response.")
@@ -81,16 +177,66 @@ class MistralLoader:
log.error(f"Failed to upload file: {e}")
raise
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
"""Async file upload with streaming for better memory efficiency."""
url = f"{self.BASE_API_URL}/files"
async def upload_request():
# Create multipart writer for streaming upload
writer = aiohttp.MultipartWriter("form-data")
# Add purpose field
purpose_part = writer.append("ocr")
purpose_part.set_content_disposition("form-data", name="purpose")
# Add file part with streaming
file_part = writer.append_payload(
aiohttp.streams.FilePayload(
self.file_path,
filename=self.file_name,
content_type="application/pdf",
)
)
file_part.set_content_disposition(
"form-data", name="file", filename=self.file_name
)
self._debug_log(
f"Uploading file: {self.file_name} ({self.file_size:,} bytes)"
)
async with session.post(
url,
data=writer,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(upload_request)
file_id = response_data.get("id")
if not file_id:
raise ValueError("File ID not found in upload response.")
log.info(f"File uploaded successfully. File ID: {file_id}")
return file_id
def _get_signed_url(self, file_id: str) -> str:
"""Retrieves a temporary signed URL for the uploaded file."""
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
log.info(f"Getting signed URL for file ID: {file_id}")
url = f"{self.BASE_API_URL}/files/{file_id}/url"
params = {"expiry": 1}
signed_url_headers = {**self.headers, "Accept": "application/json"}
def url_request():
response = requests.get(
url, headers=signed_url_headers, params=params, timeout=self.timeout
)
return self._handle_response(response)
try:
response = requests.get(url, headers=signed_url_headers, params=params)
response_data = self._handle_response(response)
response_data = self._retry_request_sync(url_request)
signed_url = response_data.get("url")
if not signed_url:
raise ValueError("Signed URL not found in response.")
@@ -100,8 +246,36 @@ class MistralLoader:
log.error(f"Failed to get signed URL: {e}")
raise
async def _get_signed_url_async(
self, session: aiohttp.ClientSession, file_id: str
) -> str:
"""Async signed URL retrieval."""
url = f"{self.BASE_API_URL}/files/{file_id}/url"
params = {"expiry": 1}
headers = {**self.headers, "Accept": "application/json"}
async def url_request():
self._debug_log(f"Getting signed URL for file ID: {file_id}")
async with session.get(
url,
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
return await self._handle_response_async(response)
response_data = await self._retry_request_async(url_request)
signed_url = response_data.get("url")
if not signed_url:
raise ValueError("Signed URL not found in response.")
self._debug_log("Signed URL received successfully")
return signed_url
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
"""Sends the signed URL to the OCR endpoint for processing."""
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
log.info("Processing OCR via Mistral API")
url = f"{self.BASE_API_URL}/ocr"
ocr_headers = {
@@ -118,43 +292,198 @@ class MistralLoader:
"include_image_base64": False,
}
def ocr_request():
response = requests.post(
url, headers=ocr_headers, json=payload, timeout=self.timeout
)
return self._handle_response(response)
try:
response = requests.post(url, headers=ocr_headers, json=payload)
ocr_response = self._handle_response(response)
ocr_response = self._retry_request_sync(ocr_request)
log.info("OCR processing done.")
log.debug("OCR response: %s", ocr_response)
self._debug_log("OCR response: %s", ocr_response)
return ocr_response
except Exception as e:
log.error(f"Failed during OCR processing: {e}")
raise
async def _process_ocr_async(
self, session: aiohttp.ClientSession, signed_url: str
) -> Dict[str, Any]:
"""Async OCR processing with timing metrics."""
url = f"{self.BASE_API_URL}/ocr"
headers = {
**self.headers,
"Content-Type": "application/json",
"Accept": "application/json",
}
payload = {
"model": "mistral-ocr-latest",
"document": {
"type": "document_url",
"document_url": signed_url,
},
"include_image_base64": False,
}
async def ocr_request():
log.info("Starting OCR processing via Mistral API")
start_time = time.time()
async with session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
ocr_response = await self._handle_response_async(response)
processing_time = time.time() - start_time
log.info(f"OCR processing completed in {processing_time:.2f}s")
return ocr_response
return await self._retry_request_async(ocr_request)
def _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage."""
"""Deletes the file from Mistral storage (sync version)."""
log.info(f"Deleting uploaded file ID: {file_id}")
url = f"{self.BASE_API_URL}/files/{file_id}"
# No specific Accept header needed, default or Authorization is usually sufficient
try:
response = requests.delete(url, headers=self.headers)
delete_response = self._handle_response(
response
) # Check status, ignore response body unless needed
log.info(
f"File deleted successfully: {delete_response}"
) # Log the response if available
response = requests.delete(url, headers=self.headers, timeout=30)
delete_response = self._handle_response(response)
log.info(f"File deleted successfully: {delete_response}")
except Exception as e:
# Log error but don't necessarily halt execution if deletion fails
log.error(f"Failed to delete file ID {file_id}: {e}")
# Depending on requirements, you might choose to raise the error here
async def _delete_file_async(
self, session: aiohttp.ClientSession, file_id: str
) -> None:
"""Async file deletion with error tolerance."""
try:
async def delete_request():
self._debug_log(f"Deleting file ID: {file_id}")
async with session.delete(
url=f"{self.BASE_API_URL}/files/{file_id}",
headers=self.headers,
timeout=aiohttp.ClientTimeout(
total=30
), # Shorter timeout for cleanup
) as response:
return await self._handle_response_async(response)
await self._retry_request_async(delete_request)
self._debug_log(f"File {file_id} deleted successfully")
except Exception as e:
# Don't fail the entire process if cleanup fails
log.warning(f"Failed to delete file ID {file_id}: {e}")
@asynccontextmanager
async def _get_session(self):
"""Context manager for HTTP session with optimized settings."""
connector = aiohttp.TCPConnector(
limit=10, # Total connection limit
limit_per_host=5, # Per-host connection limit
ttl_dns_cache=300, # DNS cache TTL
use_dns_cache=True,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
async with aiohttp.ClientSession(
connector=connector,
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
) as session:
yield session
def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]:
"""Process OCR results into Document objects with enhanced metadata."""
pages_data = ocr_response.get("pages")
if not pages_data:
log.warning("No pages found in OCR response.")
return [
Document(
page_content="No text content found", metadata={"error": "no_pages"}
)
]
documents = []
total_pages = len(pages_data)
skipped_pages = 0
for page_data in pages_data:
page_content = page_data.get("markdown")
page_index = page_data.get("index") # API uses 0-based index
if page_content is not None and page_index is not None:
# Clean up content efficiently
cleaned_content = (
page_content.strip()
if isinstance(page_content, str)
else str(page_content)
)
if cleaned_content: # Only add non-empty pages
documents.append(
Document(
page_content=cleaned_content,
metadata={
"page": page_index, # 0-based index from API
"page_label": page_index
+ 1, # 1-based label for convenience
"total_pages": total_pages,
"file_name": self.file_name,
"file_size": self.file_size,
"processing_engine": "mistral-ocr",
},
)
)
else:
skipped_pages += 1
self._debug_log(f"Skipping empty page {page_index}")
else:
skipped_pages += 1
self._debug_log(
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
)
if skipped_pages > 0:
log.info(
f"Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages"
)
if not documents:
# Case where pages existed but none had valid markdown/index
log.warning(
"OCR response contained pages, but none had valid content/index."
)
return [
Document(
page_content="No valid text content found in document",
metadata={"error": "no_valid_pages", "total_pages": total_pages},
)
]
return documents
def load(self) -> List[Document]:
"""
Executes the full OCR workflow: upload, get URL, process OCR, delete file.
Synchronous version for backward compatibility.
Returns:
A list of Document objects, one for each page processed.
"""
file_id = None
start_time = time.time()
try:
# 1. Upload file
file_id = self._upload_file()
@@ -166,53 +495,30 @@ class MistralLoader:
ocr_response = self._process_ocr(signed_url)
# 4. Process results
pages_data = ocr_response.get("pages")
if not pages_data:
log.warning("No pages found in OCR response.")
return [Document(page_content="No text content found", metadata={})]
documents = self._process_results(ocr_response)
documents = []
total_pages = len(pages_data)
for page_data in pages_data:
page_content = page_data.get("markdown")
page_index = page_data.get("index") # API uses 0-based index
if page_content is not None and page_index is not None:
documents.append(
Document(
page_content=page_content,
metadata={
"page": page_index, # 0-based index from API
"page_label": page_index
+ 1, # 1-based label for convenience
"total_pages": total_pages,
# Add other relevant metadata from page_data if available/needed
# e.g., page_data.get('width'), page_data.get('height')
},
)
)
else:
log.warning(
f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}"
)
if not documents:
# Case where pages existed but none had valid markdown/index
log.warning(
"OCR response contained pages, but none had valid content/index."
)
return [
Document(
page_content="No text content found in valid pages", metadata={}
)
]
total_time = time.time() - start_time
log.info(
f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
)
return documents
except Exception as e:
log.error(f"An error occurred during the loading process: {e}")
# Return an empty list or a specific error document on failure
return [Document(page_content=f"Error during processing: {e}", metadata={})]
total_time = time.time() - start_time
log.error(
f"An error occurred during the loading process after {total_time:.2f}s: {e}"
)
# Return an error document on failure
return [
Document(
page_content=f"Error during processing: {e}",
metadata={
"error": "processing_failed",
"file_name": self.file_name,
},
)
]
finally:
# 5. Delete file (attempt even if prior steps failed after upload)
if file_id:
@@ -223,3 +529,105 @@ class MistralLoader:
log.error(
f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}"
)
async def load_async(self) -> List[Document]:
"""
Asynchronous OCR workflow execution with optimized performance.
Returns:
A list of Document objects, one for each page processed.
"""
file_id = None
start_time = time.time()
try:
async with self._get_session() as session:
# 1. Upload file with streaming
file_id = await self._upload_file_async(session)
# 2. Get signed URL
signed_url = await self._get_signed_url_async(session, file_id)
# 3. Process OCR
ocr_response = await self._process_ocr_async(session, signed_url)
# 4. Process results
documents = self._process_results(ocr_response)
total_time = time.time() - start_time
log.info(
f"Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents"
)
return documents
except Exception as e:
total_time = time.time() - start_time
log.error(f"Async OCR workflow failed after {total_time:.2f}s: {e}")
return [
Document(
page_content=f"Error during OCR processing: {e}",
metadata={
"error": "processing_failed",
"file_name": self.file_name,
},
)
]
finally:
# 5. Cleanup - always attempt file deletion
if file_id:
try:
async with self._get_session() as session:
await self._delete_file_async(session, file_id)
except Exception as cleanup_error:
log.error(f"Cleanup failed for file ID {file_id}: {cleanup_error}")
@staticmethod
async def load_multiple_async(
loaders: List["MistralLoader"],
) -> List[List[Document]]:
"""
Process multiple files concurrently for maximum performance.
Args:
loaders: List of MistralLoader instances
Returns:
List of document lists, one for each loader
"""
if not loaders:
return []
log.info(f"Starting concurrent processing of {len(loaders)} files")
start_time = time.time()
# Process all files concurrently
tasks = [loader.load_async() for loader in loaders]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle any exceptions in results
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
log.error(f"File {i} failed: {result}")
processed_results.append(
[
Document(
page_content=f"Error processing file: {result}",
metadata={
"error": "batch_processing_failed",
"file_index": i,
},
)
]
)
else:
processed_results.append(result)
total_time = time.time() - start_time
total_docs = sum(len(docs) for docs in processed_results)
log.info(
f"Batch processing completed in {total_time:.2f}s, produced {total_docs} total documents"
)
return processed_results
@@ -0,0 +1,8 @@
from abc import ABC, abstractmethod
from typing import Optional, List, Tuple
class BaseReranker(ABC):
@abstractmethod
def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
pass
@@ -7,11 +7,13 @@ from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT:
class ColBERT(BaseReranker):
def __init__(self, name, **kwargs) -> None:
log.info("ColBERT: Loading model", name)
self.device = "cuda" if torch.cuda.is_available() else "cpu"
@@ -3,12 +3,14 @@ import requests
from typing import Optional, List, Tuple
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalReranker:
class ExternalReranker(BaseReranker):
def __init__(
self,
api_key: str,
+23 -5
View File
@@ -12,7 +12,7 @@ from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.users import UserModel
from open_webui.models.files import Files
@@ -116,6 +116,7 @@ def query_doc_with_hybrid_search(
reranking_function,
k_reranker: int,
r: float,
hybrid_bm25_weight: float,
) -> dict:
try:
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
@@ -131,9 +132,20 @@ def query_doc_with_hybrid_search(
top_k=k,
)
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever], weights=[0.5, 0.5]
)
if hybrid_bm25_weight <= 0:
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_search_retriever], weights=[1.0]
)
elif hybrid_bm25_weight >= 1:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever], weights=[1.0]
)
else:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever],
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
)
compressor = RerankCompressor(
embedding_function=embedding_function,
top_n=k_reranker,
@@ -313,6 +325,7 @@ def query_collection_with_hybrid_search(
reranking_function,
k_reranker: int,
r: float,
hybrid_bm25_weight: float,
) -> dict:
results = []
error = False
@@ -346,6 +359,7 @@ def query_collection_with_hybrid_search(
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
)
return result, None
except Exception as e:
@@ -433,6 +447,7 @@ def get_sources_from_files(
reranking_function,
k_reranker,
r,
hybrid_bm25_weight,
hybrid_search,
full_context=False,
):
@@ -550,6 +565,7 @@ def get_sources_from_files(
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
)
except Exception as e:
log.debug(
@@ -818,7 +834,9 @@ class RerankCompressor(BaseDocumentCompressor):
)
scores = util.cos_sim(query_embedding, document_embedding)[0]
docs_with_scores = list(zip(documents, scores.tolist()))
docs_with_scores = list(
zip(documents, scores.tolist() if not isinstance(scores, list) else scores)
)
if self.r_score:
docs_with_scores = [
(d, s) for d, s in docs_with_scores if s >= self.r_score
@@ -1,30 +0,0 @@
from open_webui.config import VECTOR_DB
if VECTOR_DB == "milvus":
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
VECTOR_DB_CLIENT = MilvusClient()
elif VECTOR_DB == "qdrant":
from open_webui.retrieval.vector.dbs.qdrant import QdrantClient
VECTOR_DB_CLIENT = QdrantClient()
elif VECTOR_DB == "opensearch":
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
VECTOR_DB_CLIENT = OpenSearchClient()
elif VECTOR_DB == "pgvector":
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
VECTOR_DB_CLIENT = PgvectorClient()
elif VECTOR_DB == "elasticsearch":
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
VECTOR_DB_CLIENT = ElasticsearchClient()
elif VECTOR_DB == "pinecone":
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
VECTOR_DB_CLIENT = PineconeClient()
else:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
VECTOR_DB_CLIENT = ChromaClient()
@@ -1,13 +1,12 @@
from typing import Optional, List, Dict, Any, Union
import logging
import time # for measuring elapsed time
from pinecone import ServerlessSpec
from pinecone import Pinecone, ServerlessSpec
import asyncio # for async upserts
import functools # for partial binding in async tasks
import concurrent.futures # for parallel batch upserts
from pinecone.grpc import PineconeGRPC # use gRPC client for faster upserts
from open_webui.retrieval.vector.main import (
VectorDBBase,
@@ -47,10 +46,8 @@ class PineconeClient(VectorDBBase):
self.metric = PINECONE_METRIC
self.cloud = PINECONE_CLOUD
# Initialize Pinecone gRPC client for improved performance
self.client = PineconeGRPC(
api_key=self.api_key, environment=self.environment, cloud=self.cloud
)
# Initialize Pinecone client for improved performance
self.client = Pinecone(api_key=self.api_key)
# Persistent executor for batch operations
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
@@ -147,8 +144,8 @@ class PineconeClient(VectorDBBase):
metadatas = []
for match in matches:
metadata = match.get("metadata", {})
ids.append(match["id"])
metadata = getattr(match, "metadata", {}) or {}
ids.append(match.id if hasattr(match, "id") else match["id"])
documents.append(metadata.get("text", ""))
metadatas.append(metadata)
@@ -174,7 +171,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix},
include_metadata=False,
)
return len(response.matches) > 0
matches = getattr(response, "matches", []) or []
return len(matches) > 0
except Exception as e:
log.exception(
f"Error checking collection '{collection_name_with_prefix}': {e}"
@@ -321,32 +319,6 @@ class PineconeClient(VectorDBBase):
f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'"
)
def streaming_upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Perform a streaming upsert over gRPC for performance testing."""
if not items:
log.warning("No items to upsert via streaming")
return
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
points = self._create_points(items, collection_name_with_prefix)
# Open a streaming upsert channel
stream = self.index.streaming_upsert()
try:
for point in points:
# send each point over the stream
stream.send(point)
# close the stream to finalize
stream.close()
log.info(
f"Successfully streamed upsert of {len(points)} vectors into '{collection_name_with_prefix}'"
)
except Exception as e:
log.error(f"Error during streaming upsert: {e}")
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
@@ -374,7 +346,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix},
)
if not query_response.matches:
matches = getattr(query_response, "matches", []) or []
if not matches:
# Return empty result if no matches
return SearchResult(
ids=[[]],
@@ -384,13 +357,13 @@ class PineconeClient(VectorDBBase):
)
# Convert to GetResult format
get_result = self._result_to_get_result(query_response.matches)
get_result = self._result_to_get_result(matches)
# Calculate normalized distances based on metric
distances = [
[
self._normalize_distance(match.score)
for match in query_response.matches
self._normalize_distance(getattr(match, "score", 0.0))
for match in matches
]
]
@@ -432,7 +405,8 @@ class PineconeClient(VectorDBBase):
include_metadata=True,
)
return self._result_to_get_result(query_response.matches)
matches = getattr(query_response, "matches", []) or []
return self._result_to_get_result(matches)
except Exception as e:
log.error(f"Error querying collection '{collection_name}': {e}")
@@ -456,7 +430,8 @@ class PineconeClient(VectorDBBase):
filter={"collection_name": collection_name_with_prefix},
)
return self._result_to_get_result(query_response.matches)
matches = getattr(query_response, "matches", []) or []
return self._result_to_get_result(matches)
except Exception as e:
log.error(f"Error getting collection '{collection_name}': {e}")
@@ -516,12 +491,12 @@ class PineconeClient(VectorDBBase):
raise
def close(self):
"""Shut down the gRPC channel and thread pool."""
"""Shut down resources."""
try:
self.client.close()
log.info("Pinecone gRPC channel closed.")
# The new Pinecone client doesn't need explicit closing
pass
except Exception as e:
log.warning(f"Failed to close Pinecone gRPC channel: {e}")
log.warning(f"Failed to clean up Pinecone resources: {e}")
self._executor.shutdown(wait=True)
def __enter__(self):
@@ -0,0 +1,712 @@
import logging
from typing import Optional, Tuple
from urllib.parse import urlparse
import grpc
from open_webui.config import (
QDRANT_API_KEY,
QDRANT_GRPC_PORT,
QDRANT_ON_DISK,
QDRANT_PREFER_GRPC,
QDRANT_URI,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import PointStruct
from qdrant_client.models import models
NO_LIMIT = 999999999
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open-webui"
self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
if not self.QDRANT_URI:
self.client = None
return
# Unified handling for either scheme
parsed = urlparse(self.QDRANT_URI)
host = parsed.hostname or self.QDRANT_URI
http_port = parsed.port or 6333 # default REST port
if self.PREFER_GRPC:
self.client = Qclient(
host=host,
port=http_port,
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
)
else:
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web-search"
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
def _result_to_get_result(self, points) -> GetResult:
ids = []
documents = []
metadatas = []
for point in points:
payload = point.payload
ids.append(point.id)
documents.append(payload["text"])
metadatas.append(payload["metadata"])
return GetResult(
**{
"ids": [ids],
"documents": [documents],
"metadatas": [metadatas],
}
)
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
"""
Maps the traditional collection name to multi-tenant collection and tenant ID.
Returns:
tuple: (collection_name, tenant_id)
"""
# Check for user memory collections
tenant_id = collection_name
if collection_name.startswith("user-memory-"):
return self.MEMORY_COLLECTION, tenant_id
# Check for file collections
elif collection_name.startswith("file-"):
return self.FILE_COLLECTION, tenant_id
# Check for web search collections
elif collection_name.startswith("web-search-"):
return self.WEB_SEARCH_COLLECTION, tenant_id
# Handle hash-based collections (YouTube and web URLs)
elif len(collection_name) == 63 and all(
c in "0123456789abcdef" for c in collection_name
):
return self.HASH_BASED_COLLECTION, tenant_id
else:
return self.KNOWLEDGE_COLLECTION, tenant_id
def _extract_error_message(self, exception):
"""
Extract error message from either HTTP or gRPC exceptions
Returns:
tuple: (status_code, error_message)
"""
# Check if it's an HTTP exception
if isinstance(exception, UnexpectedResponse):
try:
error_data = exception.structured()
error_msg = error_data.get("status", {}).get("error", "")
return exception.status_code, error_msg
except Exception as inner_e:
log.error(f"Failed to parse HTTP error: {inner_e}")
return exception.status_code, str(exception)
# Check if it's a gRPC exception
elif isinstance(exception, grpc.RpcError):
# Extract status code from gRPC error
status_code = None
if hasattr(exception, "code") and callable(exception.code):
status_code = exception.code().value[0]
# Extract error message
error_msg = str(exception)
if "details =" in error_msg:
# Parse the details line which contains the actual error message
try:
details_line = [
line.strip()
for line in error_msg.split("\n")
if "details =" in line
][0]
error_msg = details_line.split("details =")[1].strip(' "')
except (IndexError, AttributeError):
# Fall back to full message if parsing fails
pass
return status_code, error_msg
# For any other type of exception
return None, str(exception)
def _is_collection_not_found_error(self, exception):
"""
Check if the exception is due to collection not found, supporting both HTTP and gRPC
"""
status_code, error_msg = self._extract_error_message(exception)
# HTTP error (404)
if (
status_code == 404
and "Collection" in error_msg
and "doesn't exist" in error_msg
):
return True
# gRPC error (NOT_FOUND status)
if (
isinstance(exception, grpc.RpcError)
and exception.code() == grpc.StatusCode.NOT_FOUND
):
return True
return False
def _is_dimension_mismatch_error(self, exception):
"""
Check if the exception is due to dimension mismatch, supporting both HTTP and gRPC
"""
status_code, error_msg = self._extract_error_message(exception)
# Common patterns in both HTTP and gRPC
return (
"Vector dimension error" in error_msg
or "dimensions mismatch" in error_msg
or "invalid vector size" in error_msg
)
def _create_multi_tenant_collection_if_not_exists(
self, mt_collection_name: str, dimension: int = 384
):
"""
Creates a collection with multi-tenancy configuration if it doesn't exist.
Default dimension is set to 384 which corresponds to 'sentence-transformers/all-MiniLM-L6-v2'.
When creating collections dynamically (insert/upsert), the actual vector dimensions will be used.
"""
try:
# Try to create the collection directly - will fail if it already exists
self.client.create_collection(
collection_name=mt_collection_name,
vectors_config=models.VectorParams(
size=dimension,
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
hnsw_config=models.HnswConfigDiff(
payload_m=16, # Enable per-tenant indexing
m=0,
on_disk=self.QDRANT_ON_DISK,
),
)
# Create tenant ID payload index
self.client.create_payload_index(
collection_name=mt_collection_name,
field_name="tenant_id",
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
on_disk=self.QDRANT_ON_DISK,
),
wait=True,
)
log.info(
f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
)
except (UnexpectedResponse, grpc.RpcError) as e:
# Check for the specific error indicating collection already exists
status_code, error_msg = self._extract_error_message(e)
# HTTP status code 409 or gRPC ALREADY_EXISTS
if (isinstance(e, UnexpectedResponse) and status_code == 409) or (
isinstance(e, grpc.RpcError)
and e.code() == grpc.StatusCode.ALREADY_EXISTS
):
if "already exists" in error_msg:
log.debug(f"Collection {mt_collection_name} already exists")
return
# If it's not an already exists error, re-raise
raise e
except Exception as e:
raise e
def _create_points(self, items: list[VectorItem], tenant_id: str):
"""
Create point structs from vector items with tenant ID.
"""
return [
PointStruct(
id=item["id"],
vector=item["vector"],
payload={
"text": item["text"],
"metadata": item["metadata"],
"tenant_id": tenant_id,
},
)
for item in items
]
def has_collection(self, collection_name: str) -> bool:
"""
Check if a logical collection exists by checking for any points with the tenant ID.
"""
if not self.client:
return False
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Create tenant filter
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
try:
# Try directly querying - most of the time collection should exist
response = self.client.query_points(
collection_name=mt_collection,
query_filter=models.Filter(must=[tenant_filter]),
limit=1,
)
# Collection exists with this tenant ID if there are points
return len(response.points) > 0
except (UnexpectedResponse, grpc.RpcError) as e:
if self._is_collection_not_found_error(e):
log.debug(f"Collection {mt_collection} doesn't exist")
return False
else:
# For other API errors, log and return False
_, error_msg = self._extract_error_message(e)
log.warning(f"Unexpected Qdrant error: {error_msg}")
return False
except Exception as e:
# For any other errors, log and return False
log.debug(f"Error checking collection {mt_collection}: {e}")
return False
def delete(
self,
collection_name: str,
ids: Optional[list[str]] = None,
filter: Optional[dict] = None,
):
"""
Delete vectors by ID or filter from a collection with tenant isolation.
"""
if not self.client:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Create tenant filter
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
must_conditions = [tenant_filter]
should_conditions = []
if ids:
for id_value in ids:
should_conditions.append(
models.FieldCondition(
key="metadata.id",
match=models.MatchValue(value=id_value),
),
)
elif filter:
for key, value in filter.items():
must_conditions.append(
models.FieldCondition(
key=f"metadata.{key}",
match=models.MatchValue(value=value),
),
)
try:
# Try to delete directly - most of the time collection should exist
update_result = self.client.delete(
collection_name=mt_collection,
points_selector=models.FilterSelector(
filter=models.Filter(must=must_conditions, should=should_conditions)
),
)
return update_result
except (UnexpectedResponse, grpc.RpcError) as e:
if self._is_collection_not_found_error(e):
log.debug(
f"Collection {mt_collection} doesn't exist, nothing to delete"
)
return None
else:
# For other API errors, log and re-raise
_, error_msg = self._extract_error_message(e)
log.warning(f"Unexpected Qdrant error: {error_msg}")
raise
except Exception as e:
# For non-Qdrant exceptions, re-raise
raise
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
) -> Optional[SearchResult]:
"""
Search for the nearest neighbor items based on the vectors with tenant isolation.
"""
if not self.client:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Get the vector dimension from the query vector
dimension = len(vectors[0]) if vectors and len(vectors) > 0 else None
try:
# Try the search operation directly - most of the time collection should exist
# Create tenant filter
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
# Ensure vector dimensions match the collection
collection_dim = self.client.get_collection(
mt_collection
).config.params.vectors.size
if collection_dim != dimension:
if collection_dim < dimension:
vectors = [vector[:collection_dim] for vector in vectors]
else:
vectors = [
vector + [0] * (collection_dim - dimension)
for vector in vectors
]
# Search with tenant filter
prefetch_query = models.Prefetch(
filter=models.Filter(must=[tenant_filter]),
limit=NO_LIMIT,
)
query_response = self.client.query_points(
collection_name=mt_collection,
query=vectors[0],
prefetch=prefetch_query,
limit=limit,
)
get_result = self._result_to_get_result(query_response.points)
return SearchResult(
ids=get_result.ids,
documents=get_result.documents,
metadatas=get_result.metadatas,
# qdrant distance is [-1, 1], normalize to [0, 1]
distances=[
[(point.score + 1.0) / 2.0 for point in query_response.points]
],
)
except (UnexpectedResponse, grpc.RpcError) as e:
if self._is_collection_not_found_error(e):
log.debug(
f"Collection {mt_collection} doesn't exist, search returns None"
)
return None
else:
# For other API errors, log and re-raise
_, error_msg = self._extract_error_message(e)
log.warning(f"Unexpected Qdrant error during search: {error_msg}")
raise
except Exception as e:
# For non-Qdrant exceptions, log and return None
log.exception(f"Error searching collection '{collection_name}': {e}")
return None
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
"""
Query points with filters and tenant isolation.
"""
if not self.client:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Set default limit if not provided
if limit is None:
limit = NO_LIMIT
# Create tenant filter
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
# Create metadata filters
field_conditions = []
for key, value in filter.items():
field_conditions.append(
models.FieldCondition(
key=f"metadata.{key}", match=models.MatchValue(value=value)
)
)
# Combine tenant filter with metadata filters
combined_filter = models.Filter(must=[tenant_filter, *field_conditions])
try:
# Try the query directly - most of the time collection should exist
points = self.client.query_points(
collection_name=mt_collection,
query_filter=combined_filter,
limit=limit,
)
return self._result_to_get_result(points.points)
except (UnexpectedResponse, grpc.RpcError) as e:
if self._is_collection_not_found_error(e):
log.debug(
f"Collection {mt_collection} doesn't exist, query returns None"
)
return None
else:
# For other API errors, log and re-raise
_, error_msg = self._extract_error_message(e)
log.warning(f"Unexpected Qdrant error during query: {error_msg}")
raise
except Exception as e:
# For non-Qdrant exceptions, log and re-raise
log.exception(f"Error querying collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
"""
Get all items in a collection with tenant isolation.
"""
if not self.client:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Create tenant filter
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
try:
# Try to get points directly - most of the time collection should exist
points = self.client.query_points(
collection_name=mt_collection,
query_filter=models.Filter(must=[tenant_filter]),
limit=NO_LIMIT,
)
return self._result_to_get_result(points.points)
except (UnexpectedResponse, grpc.RpcError) as e:
if self._is_collection_not_found_error(e):
log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
return None
else:
# For other API errors, log and re-raise
_, error_msg = self._extract_error_message(e)
log.warning(f"Unexpected Qdrant error during get: {error_msg}")
raise
except Exception as e:
# For non-Qdrant exceptions, log and return None
log.exception(f"Error getting collection '{collection_name}': {e}")
return None
def _handle_operation_with_error_retry(
self, operation_name, mt_collection, points, dimension
):
"""
Private helper to handle common error cases for insert and upsert operations.
Args:
operation_name: 'insert' or 'upsert'
mt_collection: The multi-tenant collection name
points: The vector points to insert/upsert
dimension: The dimension of the vectors
Returns:
The operation result (for upsert) or None (for insert)
"""
try:
if operation_name == "insert":
self.client.upload_points(mt_collection, points)
return None
else: # upsert
return self.client.upsert(mt_collection, points)
except (UnexpectedResponse, grpc.RpcError) as e:
# Handle collection not found
if self._is_collection_not_found_error(e):
log.info(
f"Collection {mt_collection} doesn't exist. Creating it with dimension {dimension}."
)
# Create collection with correct dimensions from our vectors
self._create_multi_tenant_collection_if_not_exists(
mt_collection_name=mt_collection, dimension=dimension
)
# Try operation again - no need for dimension adjustment since we just created with correct dimensions
if operation_name == "insert":
self.client.upload_points(mt_collection, points)
return None
else: # upsert
return self.client.upsert(mt_collection, points)
# Handle dimension mismatch
elif self._is_dimension_mismatch_error(e):
# For dimension errors, the collection must exist, so get its configuration
mt_collection_info = self.client.get_collection(mt_collection)
existing_size = mt_collection_info.config.params.vectors.size
log.info(
f"Dimension mismatch: Collection {mt_collection} expects {existing_size}, got {dimension}"
)
if existing_size < dimension:
# Truncate vectors to fit
log.info(
f"Truncating vectors from {dimension} to {existing_size} dimensions"
)
points = [
PointStruct(
id=point.id,
vector=point.vector[:existing_size],
payload=point.payload,
)
for point in points
]
elif existing_size > dimension:
# Pad vectors with zeros
log.info(
f"Padding vectors from {dimension} to {existing_size} dimensions with zeros"
)
points = [
PointStruct(
id=point.id,
vector=point.vector
+ [0] * (existing_size - len(point.vector)),
payload=point.payload,
)
for point in points
]
# Try operation again with adjusted dimensions
if operation_name == "insert":
self.client.upload_points(mt_collection, points)
return None
else: # upsert
return self.client.upsert(mt_collection, points)
else:
# Not a known error we can handle, log and re-raise
_, error_msg = self._extract_error_message(e)
log.warning(f"Unhandled Qdrant error: {error_msg}")
raise
except Exception as e:
# For non-Qdrant exceptions, re-raise
raise
def insert(self, collection_name: str, items: list[VectorItem]):
"""
Insert items with tenant ID.
"""
if not self.client or not items:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Get dimensions from the actual vectors
dimension = len(items[0]["vector"]) if items else None
# Create points with tenant ID
points = self._create_points(items, tenant_id)
# Handle the operation with error retry
return self._handle_operation_with_error_retry(
"insert", mt_collection, points, dimension
)
def upsert(self, collection_name: str, items: list[VectorItem]):
"""
Upsert items with tenant ID.
"""
if not self.client or not items:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
# Get dimensions from the actual vectors
dimension = len(items[0]["vector"]) if items else None
# Create points with tenant ID
points = self._create_points(items, tenant_id)
# Handle the operation with error retry
return self._handle_operation_with_error_retry(
"upsert", mt_collection, points, dimension
)
def reset(self):
"""
Reset the database by deleting all collections.
"""
if not self.client:
return None
collection_names = self.client.get_collections().collections
for collection_name in collection_names:
if collection_name.name.startswith(self.collection_prefix):
self.client.delete_collection(collection_name=collection_name.name)
def delete_collection(self, collection_name: str):
"""
Delete a collection.
"""
if not self.client:
return None
# Map to multi-tenant collection and tenant ID
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
tenant_filter = models.FieldCondition(
key="tenant_id", match=models.MatchValue(value=tenant_id)
)
field_conditions = [tenant_filter]
update_result = self.client.delete(
collection_name=mt_collection,
points_selector=models.FilterSelector(
filter=models.Filter(must=field_conditions)
),
)
if self.client.get_collection(mt_collection).points_count == 0:
self.client.delete_collection(mt_collection)
return update_result
@@ -0,0 +1,55 @@
from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE
class Vector:
@staticmethod
def get_vector(vector_type: str) -> VectorDBBase:
"""
get vector db instance by vector type
"""
match vector_type:
case VectorType.MILVUS:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
return MilvusClient()
case VectorType.QDRANT:
if ENABLE_QDRANT_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
QdrantClient,
)
return QdrantClient()
else:
from open_webui.retrieval.vector.dbs.qdrant import QdrantClient
return QdrantClient()
case VectorType.PINECONE:
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
return PineconeClient()
case VectorType.OPENSEARCH:
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
return OpenSearchClient()
case VectorType.PGVECTOR:
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
return PgvectorClient()
case VectorType.ELASTICSEARCH:
from open_webui.retrieval.vector.dbs.elasticsearch import (
ElasticsearchClient,
)
return ElasticsearchClient()
case VectorType.CHROMA:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
return ChromaClient()
case _:
raise ValueError(f"Unsupported vector type: {vector_type}")
VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB)
@@ -0,0 +1,11 @@
from enum import StrEnum
class VectorType(StrEnum):
MILVUS = "milvus"
QDRANT = "qdrant"
CHROMA = "chroma"
PINECONE = "pinecone"
ELASTICSEARCH = "elasticsearch"
OPENSEARCH = "opensearch"
PGVECTOR = "pgvector"
@@ -42,7 +42,9 @@ def search_searchapi(
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"]
link=result["link"],
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
+3 -1
View File
@@ -42,7 +42,9 @@ def search_serpapi(
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"]
link=result["link"],
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
+5 -4
View File
@@ -25,7 +25,7 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.retrieval.loaders.external import ExternalLoader
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
@@ -39,7 +39,7 @@ from open_webui.config import (
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -515,7 +515,8 @@ class SafeWebBaseLoader(WebBaseLoader):
kwargs["ssl"] = False
async with session.get(
url, **(self.requests_kwargs | kwargs)
url,
**(self.requests_kwargs | kwargs),
) as response:
if self.raise_for_status:
response.raise_for_status()
@@ -628,7 +629,7 @@ def get_web_loader(
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
if WEB_LOADER_ENGINE.value == "external":
WebLoaderClass = ExternalLoader
WebLoaderClass = ExternalWebLoader
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
+171 -60
View File
@@ -7,6 +7,9 @@ from functools import lru_cache
from pathlib import Path
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
import aiohttp
import aiofiles
@@ -17,6 +20,7 @@ from fastapi import (
Depends,
FastAPI,
File,
Form,
HTTPException,
Request,
UploadFile,
@@ -38,6 +42,7 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
ENV,
SRC_LOG_LEVELS,
@@ -49,7 +54,7 @@ from open_webui.env import (
router = APIRouter()
# Constants
MAX_FILE_SIZE_MB = 25
MAX_FILE_SIZE_MB = 20
MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
AZURE_MAX_FILE_SIZE_MB = 200
AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
@@ -71,35 +76,50 @@ from pydub import AudioSegment
from pydub.utils import mediainfo
def get_audio_convert_format(file_path):
"""Check if the given file needs to be converted to a different format."""
def is_audio_conversion_required(file_path):
"""
Check if the given audio file needs conversion to mp3.
"""
SUPPORTED_FORMATS = {"flac", "m4a", "mp3", "mp4", "mpeg", "wav", "webm"}
if not os.path.isfile(file_path):
log.error(f"File not found: {file_path}")
return False
try:
info = mediainfo(file_path)
codec_name = info.get("codec_name", "").lower()
codec_type = info.get("codec_type", "").lower()
codec_tag_string = info.get("codec_tag_string", "").lower()
if codec_name == "aac" and codec_type == "audio" and codec_tag_string == "mp4a":
# File is AAC/mp4a audio, recommend mp3 conversion
return True
# If the codec name or file extension is in the supported formats
if (
info.get("codec_name") == "aac"
and info.get("codec_type") == "audio"
and info.get("codec_tag_string") == "mp4a"
codec_name in SUPPORTED_FORMATS
or os.path.splitext(file_path)[1][1:].lower() in SUPPORTED_FORMATS
):
return "mp4"
elif info.get("format_name") == "ogg":
return "ogg"
return False # Already supported
return True
except Exception as e:
log.error(f"Error getting audio format: {e}")
return False
return None
def convert_audio_to_wav(file_path, output_path, conversion_type):
"""Convert MP4/OGG audio file to WAV format."""
audio = AudioSegment.from_file(file_path, format=conversion_type)
audio.export(output_path, format="wav")
log.info(f"Converted {file_path} to {output_path}")
def convert_audio_to_mp3(file_path):
"""Convert audio file to mp3 format."""
try:
output_path = os.path.splitext(file_path)[0] + ".mp3"
audio = AudioSegment.from_file(file_path)
audio.export(output_path, format="mp3")
log.info(f"Converted {file_path} to {output_path}")
return output_path
except Exception as e:
log.error(f"Error converting audio file: {e}")
return None
def set_faster_whisper_model(model: str, auto_update: bool = False):
@@ -326,6 +346,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
@@ -381,6 +402,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
"Content-Type": "application/json",
"xi-api-key": request.app.state.config.TTS_API_KEY,
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
@@ -439,6 +461,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
"X-Microsoft-OutputFormat": output_format,
},
data=data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
@@ -507,12 +530,13 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
def transcribe(request: Request, file_path):
log.info(f"transcribe: {file_path}")
def transcription_handler(request, file_path, metadata):
filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path)
id = filename.split(".")[0]
metadata = metadata or {}
if request.app.state.config.STT_ENGINE == "":
if request.app.state.faster_whisper_model is None:
request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -524,7 +548,7 @@ def transcribe(request: Request, file_path):
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
language=WHISPER_LANGUAGE,
language=metadata.get("language") or WHISPER_LANGUAGE,
)
log.info(
"Detected language '%s' with probability %f"
@@ -542,19 +566,6 @@ def transcribe(request: Request, file_path):
log.debug(data)
return data
elif request.app.state.config.STT_ENGINE == "openai":
convert_format = get_audio_convert_format(file_path)
if convert_format:
ext = convert_format.split(".")[-1]
os.rename(file_path, file_path.replace(".{ext}", f".{convert_format}"))
# Convert unsupported audio file to WAV format
convert_audio_to_wav(
file_path.replace(".{ext}", f".{convert_format}"),
file_path,
convert_format,
)
r = None
try:
r = requests.post(
@@ -563,7 +574,14 @@ def transcribe(request: Request, file_path):
"Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
},
files={"file": (filename, open(file_path, "rb"))},
data={"model": request.app.state.config.STT_MODEL},
data={
"model": request.app.state.config.STT_MODEL,
**(
{"language": metadata.get("language")}
if metadata.get("language")
else {}
),
},
)
r.raise_for_status()
@@ -771,41 +789,135 @@ def transcribe(request: Request, file_path):
)
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None):
log.info(f"transcribe: {file_path} {metadata}")
if is_audio_conversion_required(file_path):
file_path = convert_audio_to_mp3(file_path)
try:
file_path = compress_audio(file_path)
except Exception as e:
log.exception(e)
# Always produce a list of chunk paths (could be one entry if small)
try:
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
print(f"Chunk paths: {chunk_paths}")
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
results = []
try:
with ThreadPoolExecutor() as executor:
# Submit tasks for each chunk_path
futures = [
executor.submit(transcription_handler, request, chunk_path, metadata)
for chunk_path in chunk_paths
]
# Gather results as they complete
for future in futures:
try:
results.append(future.result())
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error transcribing chunk: {transcribe_exc}",
)
finally:
# Clean up only the temporary chunks, never the original file
for chunk_path in chunk_paths:
if chunk_path != file_path and os.path.isfile(chunk_path):
try:
os.remove(chunk_path)
except Exception:
pass
return {
"text": " ".join([result["text"] for result in results]),
}
def compress_audio(file_path):
if os.path.getsize(file_path) > MAX_FILE_SIZE:
id = os.path.splitext(os.path.basename(file_path))[
0
] # Handles names with multiple dots
file_dir = os.path.dirname(file_path)
audio = AudioSegment.from_file(file_path)
audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio
compressed_path = f"{file_dir}/{id}_compressed.opus"
audio.export(compressed_path, format="opus", bitrate="32k")
log.debug(f"Compressed audio to {compressed_path}")
if (
os.path.getsize(compressed_path) > MAX_FILE_SIZE
): # Still larger than MAX_FILE_SIZE after compression
raise Exception(ERROR_MESSAGES.FILE_TOO_LARGE(size=f"{MAX_FILE_SIZE_MB}MB"))
compressed_path = os.path.join(file_dir, f"{id}_compressed.mp3")
audio.export(compressed_path, format="mp3", bitrate="32k")
# log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined
return compressed_path
else:
return file_path
def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"):
"""
Splits audio into chunks not exceeding max_bytes.
Returns a list of chunk file paths. If audio fits, returns list with original path.
"""
file_size = os.path.getsize(file_path)
if file_size <= max_bytes:
return [file_path] # Nothing to split
audio = AudioSegment.from_file(file_path)
duration_ms = len(audio)
orig_size = file_size
approx_chunk_ms = max(int(duration_ms * (max_bytes / orig_size)) - 1000, 1000)
chunks = []
start = 0
i = 0
base, _ = os.path.splitext(file_path)
while start < duration_ms:
end = min(start + approx_chunk_ms, duration_ms)
chunk = audio[start:end]
chunk_path = f"{base}_chunk_{i}.{format}"
chunk.export(chunk_path, format=format, bitrate=bitrate)
# Reduce chunk duration if still too large
while os.path.getsize(chunk_path) > max_bytes and (end - start) > 5000:
end = start + ((end - start) // 2)
chunk = audio[start:end]
chunk.export(chunk_path, format=format, bitrate=bitrate)
if os.path.getsize(chunk_path) > max_bytes:
os.remove(chunk_path)
raise Exception("Audio chunk cannot be reduced below max file size.")
chunks.append(chunk_path)
start = end
i += 1
return chunks
@router.post("/transcriptions")
def transcription(
request: Request,
file: UploadFile = File(...),
language: Optional[str] = Form(None),
user=Depends(get_verified_user),
):
log.info(f"file.content_type: {file.content_type}")
supported_filetypes = (
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
"audio/webm",
)
if not file.content_type.startswith(supported_filetypes):
SUPPORTED_CONTENT_TYPES = {"video/webm"} # Extend if you add more video types!
if not (
file.content_type.startswith("audio/")
or file.content_type in SUPPORTED_CONTENT_TYPES
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
@@ -826,19 +938,18 @@ def transcription(
f.write(contents)
try:
try:
file_path = compress_audio(file_path)
except Exception as e:
log.exception(e)
metadata = None
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
if language:
metadata = {"language": language}
result = transcribe(request, file_path, metadata)
return {
**result,
"filename": os.path.basename(file_path),
}
data = transcribe(request, file_path)
file_path = file_path.split("/")[-1]
return {**data, "filename": file_path}
except Exception as e:
log.exception(e)
+69 -23
View File
@@ -19,19 +19,21 @@ from open_webui.models.auths import (
UserResponse,
)
from open_webui.models.users import Users
from open_webui.models.groups import Groups
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
WEBUI_AUTH,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from fastapi.responses import RedirectResponse, Response, JSONResponse
from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
from pydantic import BaseModel
@@ -51,7 +53,7 @@ from open_webui.utils.access_control import get_permissions
from typing import Optional, List
from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
if ENABLE_LDAP.value:
from ldap3 import Server, Connection, NONE, Tls
@@ -186,6 +188,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
LDAP_VALIDATE_CERT = (
CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE
)
LDAP_CIPHERS = (
request.app.state.config.LDAP_CIPHERS
if request.app.state.config.LDAP_CIPHERS
@@ -197,7 +202,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
try:
tls = Tls(
validate=CERT_REQUIRED,
validate=LDAP_VALIDATE_CERT,
version=PROTOCOL_TLS,
ca_certs_file=LDAP_CA_CERT_FILE,
ciphers=LDAP_CIPHERS,
@@ -296,7 +301,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
500, detail="Internal error occurred during LDAP user creation."
)
user = Auths.authenticate_user_by_trusted_header(email)
user = Auths.authenticate_user_by_email(email)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
@@ -360,21 +365,29 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
trusted_name = trusted_email
email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
name = email
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
trusted_name = request.headers.get(
WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email
)
if not Users.get_user_by_email(trusted_email.lower()):
name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
if not Users.get_user_by_email(email.lower()):
await signup(
request,
response,
SignupForm(
email=trusted_email, password=str(uuid.uuid4()), name=trusted_name
),
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
)
user = Auths.authenticate_user_by_trusted_header(trusted_email)
user = Auths.authenticate_user_by_email(email)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
group_names = request.headers.get(
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
).split(",")
group_names = [name.strip() for name in group_names if name.strip()]
if group_names:
Groups.sync_user_groups_by_group_names(user.id, group_names)
elif WEBUI_AUTH == False:
admin_email = "admin@localhost"
admin_password = "admin"
@@ -478,10 +491,6 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
)
if user_count == 0:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
if len(form_data.password.encode("utf-8")) > 72:
raise HTTPException(
@@ -541,6 +550,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
user.id, request.app.state.config.USER_PERMISSIONS
)
if user_count == 0:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
return {
"token": token,
"token_type": "Bearer",
@@ -574,9 +587,14 @@ async def signout(request: Request, response: Response):
logout_url = openid_data.get("end_session_endpoint")
if logout_url:
response.delete_cookie("oauth_id_token")
return RedirectResponse(
return JSONResponse(
status_code=200,
content={
"status": True,
"redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}",
},
headers=response.headers,
url=f"{logout_url}?id_token_hint={oauth_id_token}",
)
else:
raise HTTPException(
@@ -591,12 +609,18 @@ async def signout(request: Request, response: Response):
)
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
return RedirectResponse(
return JSONResponse(
status_code=200,
content={
"status": True,
"redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
},
headers=response.headers,
url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
)
return {"status": True}
return JSONResponse(
status_code=200, content={"status": True}, headers=response.headers
)
############################
@@ -696,6 +720,9 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
}
@@ -713,6 +740,9 @@ class AdminConfig(BaseModel):
ENABLE_CHANNELS: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
PENDING_USER_OVERLAY_TITLE: Optional[str] = None
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
RESPONSE_WATERMARK: Optional[str] = None
@router.post("/admin/config")
@@ -750,6 +780,15 @@ async def update_admin_config(
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
request.app.state.config.PENDING_USER_OVERLAY_TITLE = (
form_data.PENDING_USER_OVERLAY_TITLE
)
request.app.state.config.PENDING_USER_OVERLAY_CONTENT = (
form_data.PENDING_USER_OVERLAY_CONTENT
)
request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
@@ -764,6 +803,9 @@ async def update_admin_config(
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
}
@@ -779,6 +821,7 @@ class LdapServerConfig(BaseModel):
search_filters: str = ""
use_tls: bool = True
certificate_path: Optional[str] = None
validate_cert: bool = True
ciphers: Optional[str] = "ALL"
@@ -796,6 +839,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
"use_tls": request.app.state.config.LDAP_USE_TLS,
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
"validate_cert": request.app.state.config.LDAP_VALIDATE_CERT,
"ciphers": request.app.state.config.LDAP_CIPHERS,
}
@@ -831,6 +875,7 @@ async def update_ldap_server(
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
return {
@@ -845,6 +890,7 @@ async def update_ldap_server(
"search_filters": request.app.state.config.LDAP_SEARCH_FILTERS,
"use_tls": request.app.state.config.LDAP_USE_TLS,
"certificate_path": request.app.state.config.LDAP_CA_CERT_FILE,
"validate_cert": request.app.state.config.LDAP_VALIDATE_CERT,
"ciphers": request.app.state.config.LDAP_CIPHERS,
}
+52 -7
View File
@@ -76,17 +76,34 @@ async def delete_all_user_chats(request: Request, user=Depends(get_verified_user
@router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse])
async def get_user_chat_list_by_user_id(
user_id: str,
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_admin_user),
skip: int = 0,
limit: int = 50,
):
if not ENABLE_ADMIN_CHAT_ACCESS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Chats.get_chat_list_by_user_id(
user_id, include_archived=True, skip=skip, limit=limit
user_id, include_archived=True, filter=filter, skip=skip, limit=limit
)
@@ -194,10 +211,10 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
############################
@router.get("/pinned", response_model=list[ChatResponse])
@router.get("/pinned", response_model=list[ChatTitleIdResponse])
async def get_user_pinned_chats(user=Depends(get_verified_user)):
return [
ChatResponse(**chat.model_dump())
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_pinned_chats_by_user_id(user.id)
]
@@ -267,9 +284,37 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user)):
@router.get("/archived", response_model=list[ChatTitleIdResponse])
async def get_archived_session_user_chat_list(
user=Depends(get_verified_user), skip: int = 0, limit: int = 50
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_verified_user),
):
return Chats.get_archived_chat_list_by_user_id(user.id, skip, limit)
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
chat_list = [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
)
]
return chat_list
############################
+11 -12
View File
@@ -74,13 +74,17 @@ class FeedbackUserResponse(FeedbackResponse):
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return [
FeedbackUserResponse(
**feedback.model_dump(),
user=UserResponse(**Users.get_user_by_id(feedback.user_id).model_dump()),
feedback_list = []
for feedback in feedbacks:
user = Users.get_user_by_id(feedback.user_id)
feedback_list.append(
FeedbackUserResponse(
**feedback.model_dump(),
user=UserResponse(**user.model_dump()) if user else None,
)
)
for feedback in feedbacks
]
return feedback_list
@router.delete("/feedbacks/all")
@@ -92,12 +96,7 @@ async def delete_all_feedbacks(user=Depends(get_admin_user)):
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return [
FeedbackModel(
**feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id)
)
for feedback in feedbacks
]
return feedbacks
@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse])
+51 -29
View File
@@ -1,6 +1,7 @@
import logging
import os
import uuid
import json
from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
@@ -10,6 +11,7 @@ from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
@@ -84,17 +86,44 @@ def has_access_to_file(
def upload_file(
request: Request,
file: UploadFile = File(...),
user=Depends(get_verified_user),
file_metadata: dict = None,
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
internal: bool = False,
user=Depends(get_verified_user),
):
log.info(f"file.content_type: {file.content_type}")
file_metadata = file_metadata if file_metadata else {}
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Invalid metadata format"),
)
file_metadata = metadata if metadata else {}
try:
unsanitized_filename = file.filename
filename = os.path.basename(unsanitized_filename)
file_extension = os.path.splitext(filename)[1]
# Remove the leading dot from the file extension
file_extension = file_extension[1:] if file_extension else ""
if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [
ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext
]
if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(
f"File type {file_extension} is not allowed"
),
)
# replace filename with uuid
id = str(uuid.uuid4())
name = filename
@@ -125,33 +154,26 @@ def upload_file(
)
if process:
try:
if file.content_type:
if file.content_type.startswith("audio/") or file.content_type in {
"video/webm"
}:
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata)
if file.content_type.startswith(
(
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
"audio/webm",
"video/webm",
process_file(
request,
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=id), user=user)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path)
process_file(
request,
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
elif file.content_type not in [
"image/png",
"image/jpeg",
"image/gif",
"video/mp4",
"video/ogg",
"video/quicktime",
]:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
@@ -177,7 +199,7 @@ def upload_file(
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
+113 -21
View File
@@ -1,5 +1,8 @@
import os
import re
import logging
import aiohttp
from pathlib import Path
from typing import Optional
@@ -9,12 +12,18 @@ from open_webui.models.functions import (
FunctionResponse,
Functions,
)
from open_webui.utils.plugin import load_function_module_by_id, replace_imports
from open_webui.utils.plugin import (
load_function_module_by_id,
replace_imports,
get_function_module_from_cache,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, HttpUrl
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
@@ -42,6 +51,97 @@ async def get_functions(user=Depends(get_admin_user)):
return Functions.get_functions()
############################
# LoadFunctionFromLink
############################
class LoadUrlForm(BaseModel):
url: HttpUrl
def github_url_to_raw_url(url: str) -> str:
# Handle 'tree' (folder) URLs (add main.py at the end)
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
if m1:
org, repo, branch, path = m1.groups()
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
# Handle 'blob' (file) URLs
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
if m2:
org, repo, branch, path = m2.groups()
return (
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
)
# No match; return as-is
return url
@router.post("/load/url", response_model=Optional[dict])
async def load_function_from_url(
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
):
# NOTE: This is NOT a SSRF vulnerability:
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
# and does NOT accept untrusted user input. Access is enforced by authentication.
url = str(form_data.url)
if not url:
raise HTTPException(status_code=400, detail="Please enter a valid URL")
url = github_url_to_raw_url(url)
url_parts = url.rstrip("/").split("/")
file_name = url_parts[-1]
function_name = (
file_name[:-3]
if (
file_name.endswith(".py")
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
)
else url_parts[-2] if len(url_parts) > 1 else "function"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=resp.status, detail="Failed to fetch the function"
)
data = await resp.text()
if not data:
raise HTTPException(
status_code=400, detail="No data received from the URL"
)
return {
"name": function_name,
"content": data,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error importing function: {e}")
############################
# SyncFunctions
############################
class SyncFunctionsForm(FunctionForm):
functions: list[FunctionModel] = []
@router.post("/sync", response_model=Optional[FunctionModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
return Functions.sync_functions(user.id, form_data.functions)
############################
# CreateNewFunction
############################
@@ -262,11 +362,9 @@ async def get_function_valves_spec_by_id(
):
function = Functions.get_function_by_id(id)
if function:
if id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[id]
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
)
if hasattr(function_module, "Valves"):
Valves = function_module.Valves
@@ -290,11 +388,9 @@ async def update_function_valves_by_id(
):
function = Functions.get_function_by_id(id)
if function:
if id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[id]
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
)
if hasattr(function_module, "Valves"):
Valves = function_module.Valves
@@ -353,11 +449,9 @@ async def get_function_user_valves_spec_by_id(
):
function = Functions.get_function_by_id(id)
if function:
if id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[id]
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
)
if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves
@@ -377,11 +471,9 @@ async def update_function_user_valves_by_id(
function = Functions.get_function_by_id(id)
if function:
if id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[id]
else:
function_module, function_type, frontmatter = load_function_module_by_id(id)
request.app.state.FUNCTIONS[id] = function_module
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
)
if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves
+8 -7
View File
@@ -333,10 +333,11 @@ def get_models(request: Request, user=Depends(get_verified_user)):
return [
{"id": "dall-e-2", "name": "DALL·E 2"},
{"id": "dall-e-3", "name": "DALL·E 3"},
{"id": "gpt-image-1", "name": "GPT-IMAGE 1"},
]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
return [
{"id": "imagen-3-0-generate-002", "name": "imagen-3.0 generate-002"},
{"id": "imagen-3.0-generate-002", "name": "imagen-3.0 generate-002"},
]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
# TODO - get models from comfyui
@@ -450,7 +451,7 @@ def load_url_image_data(url, headers=None):
return None
def upload_image(request, image_metadata, image_data, content_type, user):
def upload_image(request, image_data, content_type, metadata, user):
image_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(image_data),
@@ -459,7 +460,7 @@ def upload_image(request, image_metadata, image_data, content_type, user):
"content-type": content_type,
},
)
file_item = upload_file(request, file, user, file_metadata=image_metadata)
file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
@@ -526,7 +527,7 @@ async def image_generations(
else:
image_data, content_type = load_b64_image_data(image["b64_json"])
url = upload_image(request, data, image_data, content_type, user)
url = upload_image(request, image_data, content_type, data, user)
images.append({"url": url})
return images
@@ -560,7 +561,7 @@ async def image_generations(
image_data, content_type = load_b64_image_data(
image["bytesBase64Encoded"]
)
url = upload_image(request, data, image_data, content_type, user)
url = upload_image(request, image_data, content_type, data, user)
images.append({"url": url})
return images
@@ -611,9 +612,9 @@ async def image_generations(
image_data, content_type = load_url_image_data(image["url"], headers)
url = upload_image(
request,
form_data.model_dump(exclude_none=True),
image_data,
content_type,
form_data.model_dump(exclude_none=True),
user,
)
images.append({"url": url})
@@ -664,9 +665,9 @@ async def image_generations(
image_data, content_type = load_b64_image_data(image)
url = upload_image(
request,
{**data, "info": res["info"]},
image_data,
content_type,
{**data, "info": res["info"]},
user,
)
images.append({"url": url})
+1 -1
View File
@@ -10,7 +10,7 @@ from open_webui.models.knowledge import (
KnowledgeUserResponse,
)
from open_webui.models.files import Files, FileModel, FileMetadataResponse
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.routers.retrieval import (
process_file,
ProcessFileForm,
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
from typing import Optional
from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.auth import get_verified_user
from open_webui.env import SRC_LOG_LEVELS
+174 -49
View File
@@ -9,6 +9,8 @@ import os
import random
import re
import time
from datetime import datetime
from typing import Optional, Union
from urllib.parse import urlparse
import aiohttp
@@ -300,6 +302,22 @@ async def update_config(
}
def merge_ollama_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
@cached(ttl=1)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
@@ -340,6 +358,8 @@ async def get_all_models(request: Request, user: UserModel = None):
), # Legacy support
)
connection_type = api_config.get("connection_type", "local")
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
model_ids = api_config.get("model_ids", [])
@@ -352,31 +372,18 @@ async def get_all_models(request: Request, user: UserModel = None):
)
)
if prefix_id:
for model in response.get("models", []):
for model in response.get("models", []):
if prefix_id:
model["model"] = f"{prefix_id}.{model['model']}"
if tags:
for model in response.get("models", []):
if tags:
model["tags"] = tags
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
if connection_type:
model["connection_type"] = connection_type
models = {
"models": merge_models_lists(
"models": merge_ollama_models_lists(
map(
lambda response: response.get("models", []) if response else None,
responses,
@@ -384,6 +391,22 @@ async def get_all_models(request: Request, user: UserModel = None):
)
}
try:
loaded_models = await get_ollama_loaded_models(request, user=user)
expires_map = {
m["name"]: m["expires_at"]
for m in loaded_models["models"]
if "expires_at" in m
}
for m in models["models"]:
if m["name"] in expires_map:
# Parse ISO8601 datetime with offset, get unix timestamp as int
dt = datetime.fromisoformat(expires_map[m["name"]])
m["expires_at"] = int(dt.timestamp())
except Exception as e:
log.debug(f"Failed to get loaded models: {e}")
else:
models = {"models": []}
@@ -464,6 +487,68 @@ async def get_ollama_tags(
return models
@router.get("/api/ps")
async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user)):
"""
List models that are currently loaded into Ollama memory, and which node they are loaded on.
"""
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/ps", user=user))
else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
key = api_config.get("key", None)
if enable:
request_tasks.append(
send_get_request(f"{url}/api/ps", key, user=user)
)
else:
request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
responses = await asyncio.gather(*request_tasks)
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
for model in response.get("models", []):
if prefix_id:
model["model"] = f"{prefix_id}.{model['model']}"
models = {
"models": merge_ollama_models_lists(
map(
lambda response: response.get("models", []) if response else None,
responses,
)
)
}
else:
models = {"models": []}
return models
@router.get("/api/version")
@router.get("/api/version/{url_idx}")
async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
@@ -537,36 +622,74 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
return {"version": False}
@router.get("/api/ps")
async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_user)):
"""
List models that are currently loaded into Ollama memory, and which node they are loaded on.
"""
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = [
send_get_request(
f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
user=user,
)
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
return dict(zip(request.app.state.config.OLLAMA_BASE_URLS, responses))
else:
return {}
class ModelNameForm(BaseModel):
name: str
@router.post("/api/unload")
async def unload_model(
request: Request,
form_data: ModelNameForm,
user=Depends(get_admin_user),
):
model_name = form_data.name
if not model_name:
raise HTTPException(
status_code=400, detail="Missing 'name' of model to unload."
)
# Refresh/load models if needed, get mapping from name to URLs
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
# Canonicalize model name (if not supplied with version)
if ":" not in model_name:
model_name = f"{model_name}:latest"
if model_name not in models:
raise HTTPException(
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
)
url_indices = models[model_name]["urls"]
# Send unload to ALL url_indices
results = []
errors = []
for idx in url_indices:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
)
key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get("prefix_id", None)
if prefix_id and model_name.startswith(f"{prefix_id}."):
model_name = model_name[len(f"{prefix_id}.") :]
payload = {"model": model_name, "keep_alive": 0, "prompt": ""}
try:
res = await send_post_request(
url=f"{url}/api/generate",
payload=json.dumps(payload),
stream=False,
key=key,
user=user,
)
results.append({"url_idx": idx, "success": True, "response": res})
except Exception as e:
log.exception(f"Failed to unload model on node {idx}: {e}")
errors.append({"url_idx": idx, "success": False, "error": str(e)})
if len(errors) > 0:
raise HTTPException(
status_code=500,
detail=f"Failed to unload model on {len(errors)} nodes: {errors}",
)
return {"status": True}
@router.post("/api/pull")
@router.post("/api/pull/{url_idx}")
async def pull_model(
@@ -1585,7 +1708,9 @@ async def upload_model(
if url_idx is None:
url_idx = 0
ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
file_path = os.path.join(UPLOAD_DIR, file.filename)
filename = os.path.basename(file.filename)
file_path = os.path.join(UPLOAD_DIR, filename)
os.makedirs(UPLOAD_DIR, exist_ok=True)
# --- P1: save file locally ---
@@ -1630,13 +1755,13 @@ async def upload_model(
os.remove(file_path)
# Create model in ollama
model_name, ext = os.path.splitext(file.filename)
model_name, ext = os.path.splitext(filename)
log.info(f"Created Model: {model_name}") # DEBUG
create_payload = {
"model": model_name,
# Reference the file by its original name => the uploaded blob's digest
"files": {file.filename: f"sha256:{file_hash}"},
"files": {filename: f"sha256:{file_hash}"},
}
log.info(f"Model Payload: {create_payload}") # DEBUG
@@ -1653,7 +1778,7 @@ async def upload_model(
done_msg = {
"done": True,
"blob": f"sha256:{file_hash}",
"name": file.filename,
"name": filename,
"model_created": model_name,
}
yield f"data: {json.dumps(done_msg)}\n\n"
+251 -118
View File
@@ -353,21 +353,22 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
), # Legacy support
)
connection_type = api_config.get("connection_type", "external")
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
if prefix_id:
for model in (
response if isinstance(response, list) else response.get("data", [])
):
for model in (
response if isinstance(response, list) else response.get("data", [])
):
if prefix_id:
model["id"] = f"{prefix_id}.{model['id']}"
if tags:
for model in (
response if isinstance(response, list) else response.get("data", [])
):
if tags:
model["tags"] = tags
if connection_type:
model["connection_type"] = connection_type
log.debug(f"get_all_models:responses() {responses}")
return responses
@@ -415,6 +416,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
"name": model.get("name", model["id"]),
"owned_by": "openai",
"openai": model,
"connection_type": model.get("connection_type", "external"),
"urlIdx": idx,
}
for model in models
@@ -461,60 +463,74 @@ async def get_models(
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
key = request.app.state.config.OPENAI_API_KEYS[url_idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
async with session.get(
f"{url}/models",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
response_data = await r.json()
if api_config.get("azure", False):
models = {
"data": api_config.get("model_ids", []) or [],
"object": "list",
}
else:
headers["Authorization"] = f"Bearer {key}"
# Check if we're calling OpenAI API based on the URL
if "api.openai.com" in url:
# Filter models according to the specified conditions
response_data["data"] = [
model
for model in response_data.get("data", [])
if not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
)
]
async with session.get(
f"{url}/models",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
models = response_data
response_data = await r.json()
# Check if we're calling OpenAI API based on the URL
if "api.openai.com" in url:
# Filter models according to the specified conditions
response_data["data"] = [
model
for model in response_data.get("data", [])
if not any(
name in model["id"]
for name in [
"babbage",
"dall-e",
"davinci",
"embedding",
"tts",
"whisper",
]
)
]
models = response_data
except aiohttp.ClientError as e:
# ClientError covers all aiohttp requests issues
log.exception(f"Client error: {str(e)}")
@@ -536,6 +552,8 @@ class ConnectionVerificationForm(BaseModel):
url: str
key: str
config: Optional[dict] = None
@router.post("/verify")
async def verify_connection(
@@ -544,39 +562,64 @@ async def verify_connection(
url = form_data.url
key = form_data.key
api_config = form_data.config or {}
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
async with session.get(
f"{url}/models",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
response_data = await r.json()
return response_data
if api_config.get("azure", False):
headers["api-key"] = key
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
async with session.get(
url=f"{url}/openai/models?api-version={api_version}",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
response_data = await r.json()
return response_data
else:
headers["Authorization"] = f"Bearer {key}"
async with session.get(
f"{url}/models",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
res = await r.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
raise Exception(error_detail)
response_data = await r.json()
return response_data
except aiohttp.ClientError as e:
# ClientError covers all aiohttp requests issues
@@ -590,6 +633,63 @@ async def verify_connection(
raise HTTPException(status_code=500, detail=error_detail)
def convert_to_azure_payload(
url,
payload: dict,
):
model = payload.get("model", "")
# Filter allowed parameters based on Azure OpenAI API
allowed_params = {
"messages",
"temperature",
"role",
"content",
"contentPart",
"contentPartImage",
"enhancements",
"dataSources",
"n",
"stream",
"stop",
"max_tokens",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"user",
"function_call",
"functions",
"tools",
"tool_choice",
"top_p",
"log_probs",
"top_logprobs",
"response_format",
"seed",
"max_completion_tokens",
}
# Special handling for o-series models
if model.startswith("o") and model.endswith("-mini"):
# Convert max_tokens to max_completion_tokens for o-series models
if "max_tokens" in payload:
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
# Remove temperature if not 1 for o-series models
if "temperature" in payload and payload["temperature"] != 1:
log.debug(
f"Removing temperature parameter for o-series model {model} as only default value (1) is supported"
)
del payload["temperature"]
# Filter out unsupported parameters
payload = {k: v for k, v in payload.items() if k in allowed_params}
url = f"{url}/openai/deployments/{model}"
return url, payload
@router.post("/chat/completions")
async def generate_chat_completion(
request: Request,
@@ -690,6 +790,38 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False):
request_url, payload = convert_to_azure_payload(url, payload)
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
headers["api-key"] = key
headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
headers["Authorization"] = f"Bearer {key}"
payload = json.dumps(payload)
r = None
@@ -704,30 +836,9 @@ async def generate_chat_completion(
r = await session.request(
method="POST",
url=f"{url}/chat/completions",
url=request_url,
data=payload,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -783,31 +894,53 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
idx = 0
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
)
r = None
session = None
streaming = False
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False):
headers["api-key"] = key
headers["api-version"] = (
api_config.get("api_version", "") or "2023-03-15-preview"
)
payload = json.loads(body)
url, payload = convert_to_azure_payload(url, payload)
body = json.dumps(payload).encode()
request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
else:
headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
r = await session.request(
method=request.method,
url=f"{url}/{path}",
url=request_url,
data=body,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": user.name,
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
+15 -5
View File
@@ -18,7 +18,7 @@ from pydantic import BaseModel
from starlette.responses import FileResponse
from typing import Optional
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
@@ -69,7 +69,10 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
try:
urlIdx = int(urlIdx)
except:
continue
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
@@ -89,6 +92,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
f"{url}/{filter['id']}/filter/inlet",
headers=headers,
json=request_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
payload = await response.json()
response.raise_for_status()
@@ -118,7 +122,10 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
try:
urlIdx = int(urlIdx)
except:
continue
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
@@ -138,6 +145,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
f"{url}/{filter['id']}/filter/outlet",
headers=headers,
json=request_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
payload = await response.json()
response.raise_for_status()
@@ -197,8 +205,10 @@ async def upload_pipeline(
user=Depends(get_admin_user),
):
log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}")
filename = os.path.basename(file.filename)
# Check if the uploaded file is a python file
if not (file.filename and file.filename.endswith(".py")):
if not (filename and filename.endswith(".py")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only Python (.py) files are allowed.",
@@ -206,7 +216,7 @@ async def upload_pipeline(
upload_folder = f"{CACHE_DIR}/pipelines"
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join(upload_folder, file.filename)
file_path = os.path.join(upload_folder, filename)
r = None
try:
+168 -8
View File
@@ -36,7 +36,7 @@ from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
# Document loaders
from open_webui.retrieval.loaders.main import Loader
@@ -349,13 +349,26 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"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_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_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"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,
@@ -371,6 +384,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
# File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
"FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT,
"ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS,
# Integration settings
"ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
@@ -383,6 +397,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"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,
"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,
@@ -435,6 +450,7 @@ class WebConfig(BaseModel):
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
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
SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None
@@ -488,14 +504,28 @@ class ConfigForm(BaseModel):
ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None
TOP_K_RERANKER: Optional[int] = None
RELEVANCE_THRESHOLD: Optional[float] = None
HYBRID_BM25_WEIGHT: Optional[float] = None
# Content extraction settings
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None
DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_LANGS: Optional[str] = None
DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None
DATALAB_MARKER_FORCE_OCR: Optional[bool] = None
DATALAB_MARKER_PAGINATE: Optional[bool] = None
DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None
DATALAB_MARKER_USE_LLM: Optional[bool] = None
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None
TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None
DOCLING_OCR_ENGINE: Optional[str] = None
DOCLING_OCR_LANG: Optional[str] = None
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None
DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
MISTRAL_OCR_API_KEY: Optional[str] = None
@@ -514,6 +544,7 @@ class ConfigForm(BaseModel):
# File upload settings
FILE_MAX_SIZE: Optional[int] = None
FILE_MAX_COUNT: Optional[int] = None
ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None
# Integration settings
ENABLE_GOOGLE_DRIVE_INTEGRATION: Optional[bool] = None
@@ -569,6 +600,11 @@ async def update_rag_config(
if form_data.RELEVANCE_THRESHOLD is not None
else request.app.state.config.RELEVANCE_THRESHOLD
)
request.app.state.config.HYBRID_BM25_WEIGHT = (
form_data.HYBRID_BM25_WEIGHT
if form_data.HYBRID_BM25_WEIGHT is not None
else request.app.state.config.HYBRID_BM25_WEIGHT
)
# Content extraction settings
request.app.state.config.CONTENT_EXTRACTION_ENGINE = (
@@ -581,6 +617,61 @@ async def update_rag_config(
if form_data.PDF_EXTRACT_IMAGES is not None
else request.app.state.config.PDF_EXTRACT_IMAGES
)
request.app.state.config.DATALAB_MARKER_API_KEY = (
form_data.DATALAB_MARKER_API_KEY
if form_data.DATALAB_MARKER_API_KEY is not None
else request.app.state.config.DATALAB_MARKER_API_KEY
)
request.app.state.config.DATALAB_MARKER_LANGS = (
form_data.DATALAB_MARKER_LANGS
if form_data.DATALAB_MARKER_LANGS is not None
else request.app.state.config.DATALAB_MARKER_LANGS
)
request.app.state.config.DATALAB_MARKER_SKIP_CACHE = (
form_data.DATALAB_MARKER_SKIP_CACHE
if form_data.DATALAB_MARKER_SKIP_CACHE is not None
else request.app.state.config.DATALAB_MARKER_SKIP_CACHE
)
request.app.state.config.DATALAB_MARKER_FORCE_OCR = (
form_data.DATALAB_MARKER_FORCE_OCR
if form_data.DATALAB_MARKER_FORCE_OCR is not None
else request.app.state.config.DATALAB_MARKER_FORCE_OCR
)
request.app.state.config.DATALAB_MARKER_PAGINATE = (
form_data.DATALAB_MARKER_PAGINATE
if form_data.DATALAB_MARKER_PAGINATE is not None
else request.app.state.config.DATALAB_MARKER_PAGINATE
)
request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = (
form_data.DATALAB_MARKER_STRIP_EXISTING_OCR
if form_data.DATALAB_MARKER_STRIP_EXISTING_OCR is not None
else request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR
)
request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None
else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = (
form_data.DATALAB_MARKER_OUTPUT_FORMAT
if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None
else request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT
)
request.app.state.config.DATALAB_MARKER_USE_LLM = (
form_data.DATALAB_MARKER_USE_LLM
if form_data.DATALAB_MARKER_USE_LLM is not None
else request.app.state.config.DATALAB_MARKER_USE_LLM
)
request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = (
form_data.EXTERNAL_DOCUMENT_LOADER_URL
if form_data.EXTERNAL_DOCUMENT_LOADER_URL is not None
else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL
)
request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = (
form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY
if form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY is not None
else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY
)
request.app.state.config.TIKA_SERVER_URL = (
form_data.TIKA_SERVER_URL
if form_data.TIKA_SERVER_URL is not None
@@ -601,6 +692,13 @@ async def update_rag_config(
if form_data.DOCLING_OCR_LANG is not None
else request.app.state.config.DOCLING_OCR_LANG
)
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
form_data.DOCLING_DO_PICTURE_DESCRIPTION
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
else request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION
)
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.DOCUMENT_INTELLIGENCE_ENDPOINT
if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None
@@ -688,6 +786,11 @@ async def update_rag_config(
if form_data.FILE_MAX_COUNT is not None
else request.app.state.config.FILE_MAX_COUNT
)
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
form_data.ALLOWED_FILE_EXTENSIONS
if form_data.ALLOWED_FILE_EXTENSIONS is not None
else request.app.state.config.ALLOWED_FILE_EXTENSIONS
)
# Integration settings
request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = (
@@ -720,6 +823,9 @@ async def update_rag_config(
request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
)
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
@@ -806,13 +912,26 @@ async def update_rag_config(
"ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
"TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER,
"RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD,
"HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT,
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS,
"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_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_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"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,
@@ -828,6 +947,7 @@ async def update_rag_config(
# File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
"FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT,
"ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS,
# Integration settings
"ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
@@ -840,6 +960,7 @@ async def update_rag_config(
"WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
"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,
"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,
@@ -1129,10 +1250,22 @@ def process_file(
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_LANGS=request.app.state.config.DATALAB_MARKER_LANGS,
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_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_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG,
DOCLING_DO_PICTURE_DESCRIPTION=request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
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,
@@ -1640,13 +1773,29 @@ async def process_web_search(
)
try:
loader = get_web_loader(
urls,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
docs = [
Document(
page_content=result.snippet,
metadata={
"source": result.link,
"title": result.title,
"snippet": result.snippet,
"link": result.link,
},
)
for result in search_results
if hasattr(result, "snippet")
]
else:
loader = get_web_loader(
urls,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader
@@ -1736,6 +1885,11 @@ def query_doc_handler(
if form_data.r
else request.app.state.config.RELEVANCE_THRESHOLD
),
hybrid_bm25_weight=(
form_data.hybrid_bm25_weight
if form_data.hybrid_bm25_weight
else request.app.state.config.HYBRID_BM25_WEIGHT
),
user=user,
)
else:
@@ -1762,6 +1916,7 @@ class QueryCollectionsForm(BaseModel):
k_reranker: Optional[int] = None
r: Optional[float] = None
hybrid: Optional[bool] = None
hybrid_bm25_weight: Optional[float] = None
@router.post("/query/collection")
@@ -1787,6 +1942,11 @@ def query_collection_handler(
if form_data.r
else request.app.state.config.RELEVANCE_THRESHOLD
),
hybrid_bm25_weight=(
form_data.hybrid_bm25_weight
if form_data.hybrid_bm25_weight
else request.app.state.config.HYBRID_BM25_WEIGHT
),
)
else:
return query_collection(
+7 -6
View File
@@ -20,10 +20,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.constants import TASKS
from open_webui.routers.pipelines import process_pipeline_inlet_filter
from open_webui.utils.filter import (
get_sorted_filter_ids,
process_filter_functions,
)
from open_webui.utils.task import get_task_model_id
from open_webui.config import (
@@ -195,15 +192,19 @@ async def generate_title(
},
)
max_tokens = (
models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000)
)
payload = {
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
**(
{"max_tokens": 1000}
{"max_tokens": max_tokens}
if models[task_model_id].get("owned_by") == "ollama"
else {
"max_completion_tokens": 1000,
"max_completion_tokens": max_tokens,
}
),
"metadata": {
+81 -2
View File
@@ -2,6 +2,9 @@ import logging
from pathlib import Path
from typing import Optional
import time
import re
import aiohttp
from pydantic import BaseModel, HttpUrl
from open_webui.models.tools import (
ToolForm,
@@ -21,6 +24,7 @@ from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.tools import get_tool_servers_data
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
@@ -51,11 +55,11 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
**{
"id": f"server:{server['idx']}",
"user_id": f"server:{server['idx']}",
"name": server["openapi"]
"name": server.get("openapi", {})
.get("info", {})
.get("title", "Tool Server"),
"meta": {
"description": server["openapi"]
"description": server.get("openapi", {})
.get("info", {})
.get("description", ""),
},
@@ -95,6 +99,81 @@ async def get_tool_list(user=Depends(get_verified_user)):
return tools
############################
# LoadFunctionFromLink
############################
class LoadUrlForm(BaseModel):
url: HttpUrl
def github_url_to_raw_url(url: str) -> str:
# Handle 'tree' (folder) URLs (add main.py at the end)
m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url)
if m1:
org, repo, branch, path = m1.groups()
return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py"
# Handle 'blob' (file) URLs
m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url)
if m2:
org, repo, branch, path = m2.groups()
return (
f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}"
)
# No match; return as-is
return url
@router.post("/load/url", response_model=Optional[dict])
async def load_tool_from_url(
request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)
):
# NOTE: This is NOT a SSRF vulnerability:
# This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use,
# and does NOT accept untrusted user input. Access is enforced by authentication.
url = str(form_data.url)
if not url:
raise HTTPException(status_code=400, detail="Please enter a valid URL")
url = github_url_to_raw_url(url)
url_parts = url.rstrip("/").split("/")
file_name = url_parts[-1]
tool_name = (
file_name[:-3]
if (
file_name.endswith(".py")
and (not file_name.startswith(("main.py", "index.py", "__init__.py")))
)
else url_parts[-2] if len(url_parts) > 1 else "function"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=resp.status, detail="Failed to fetch the tool"
)
data = await resp.text()
if not data:
raise HTTPException(
status_code=400, detail="No data received from the URL"
)
return {
"name": tool_name,
"content": data,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error importing tool: {e}")
############################
# ExportTools
############################
@@ -269,11 +269,6 @@ tbody + tbody {
margin-bottom: 0;
}
/* Add a rule to reset margin-bottom for <p> not followed by <ul> */
.markdown-section p + ul {
margin-top: 0;
}
/* List item styles */
.markdown-section li {
padding: 2px;
+15 -1
View File
@@ -2,6 +2,7 @@ import os
import shutil
import json
import logging
import re
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple, Dict
@@ -136,6 +137,11 @@ class S3StorageProvider(StorageProvider):
self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
@staticmethod
def sanitize_tag_value(s: str) -> str:
"""Only include S3 allowed characters."""
return re.sub(r"[^a-zA-Z0-9 äöüÄÖÜß\+\-=\._:/@]", "", s)
def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
@@ -145,7 +151,15 @@ class S3StorageProvider(StorageProvider):
try:
self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
if S3_ENABLE_TAGGING and tags:
tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]}
sanitized_tags = {
self.sanitize_tag_value(k): self.sanitize_tag_value(v)
for k, v in tags.items()
}
tagging = {
"TagSet": [
{"Key": k, "Value": v} for k, v in sanitized_tags.items()
]
}
self.s3_client.put_object_tagging(
Bucket=self.bucket_name,
Key=s3_key,
+29 -1
View File
@@ -13,6 +13,8 @@ import pytz
from pytz import UTC
from typing import Optional, Union, List, Dict
from opentelemetry import trace
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
@@ -194,7 +196,17 @@ def get_current_user(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
return get_current_user_by_api_key(token)
user = get_current_user_by_api_key(token)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
return user
# auth by jwt token
try:
@@ -213,6 +225,14 @@ def get_current_user(
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
else:
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
@@ -234,6 +254,14 @@ def get_current_user_by_api_key(api_key: str):
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
else:
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "api_key")
Users.update_user_last_active_by_id(user.id)
return user
+9 -7
View File
@@ -40,7 +40,10 @@ from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.models import get_all_models, check_model_access
from open_webui.utils.payload import convert_payload_openai_to_ollama
from open_webui.utils.response import (
@@ -309,6 +312,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
metadata = {
"chat_id": data["chat_id"],
"message_id": data["id"],
"filter_ids": data.get("filter_ids", []),
"session_id": data["session_id"],
"user_id": user.id,
}
@@ -330,7 +334,9 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
for filter_id in get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
]
result, _ = await process_filter_functions(
@@ -389,11 +395,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
}
)
if action_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[action_id]
else:
function_module, _, _ = load_function_module_by_id(action_id)
request.app.state.FUNCTIONS[action_id] = function_module
function_module, _, _ = get_function_module_from_cache(request, action_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(action_id)
+33 -11
View File
@@ -1,7 +1,10 @@
import inspect
import logging
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.models.functions import Functions
from open_webui.env import SRC_LOG_LEVELS
@@ -9,7 +12,17 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_sorted_filter_ids(model: dict):
def get_function_module(request, function_id, load_from_db=True):
"""
Get the function module by its ID.
"""
function_module, _, _ = get_function_module_from_cache(
request, function_id, load_from_db
)
return function_module
def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None:
@@ -21,14 +34,26 @@ def get_sorted_filter_ids(model: dict):
if "info" in model and "meta" in model["info"]:
filter_ids.extend(model["info"]["meta"].get("filterIds", []))
filter_ids = list(set(filter_ids))
enabled_filter_ids = [
active_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
filter_ids = [fid for fid in filter_ids if fid in enabled_filter_ids]
def get_active_status(filter_id):
function_module = get_function_module(request, filter_id)
if getattr(function_module, "toggle", None):
return filter_id in (enabled_filter_ids or [])
return True
active_filter_ids = [
filter_id for filter_id in active_filter_ids if get_active_status(filter_id)
]
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids.sort(key=get_priority)
return filter_ids
@@ -43,12 +68,9 @@ async def process_filter_functions(
if not filter:
continue
if filter_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[filter_id]
else:
function_module, _, _ = load_function_module_by_id(filter_id)
request.app.state.FUNCTIONS[filter_id] = function_module
function_module = get_function_module(
request, filter_id, load_from_db=(filter_type != "stream")
)
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
+131 -40
View File
@@ -41,6 +41,7 @@ from open_webui.routers.pipelines import (
process_pipeline_inlet_filter,
process_pipeline_outlet_filter,
)
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook
@@ -251,7 +252,12 @@ async def chat_completion_tools_handler(
"name": (f"TOOL:{tool_name}"),
},
"document": [tool_result],
"metadata": [{"source": (f"TOOL:{tool_name}")}],
"metadata": [
{
"source": (f"TOOL:{tool_name}"),
"parameters": tool_function_params,
}
],
}
)
else:
@@ -290,6 +296,38 @@ async def chat_completion_tools_handler(
return body, {"sources": sources}
async def chat_memory_handler(
request: Request, form_data: dict, extra_params: dict, user
):
results = await query_memory(
request,
QueryMemoryForm(
**{"content": get_last_user_message(form_data["messages"]), "k": 3}
),
user,
)
user_context = ""
if results and hasattr(results, "documents"):
if results.documents and len(results.documents) > 0:
for doc_idx, doc in enumerate(results.documents[0]):
created_at_date = "Unknown Date"
if results.metadatas[0][doc_idx].get("created_at"):
created_at_timestamp = results.metadatas[0][doc_idx]["created_at"]
created_at_date = time.strftime(
"%Y-%m-%d", time.localtime(created_at_timestamp)
)
user_context += f"{doc_idx + 1}. [{created_at_date}] {doc}\n"
form_data["messages"] = add_or_update_system_message(
f"User Context:\n{user_context}\n", form_data["messages"], append=True
)
return form_data
async def chat_web_search_handler(
request: Request, form_data: dict, extra_params: dict, user
):
@@ -340,6 +378,11 @@ async def chat_web_search_handler(
log.exception(e)
queries = [user_message]
# Check if generated queries are empty
if len(queries) == 1 and queries[0].strip() == "":
queries = [user_message]
# Check if queries are not found
if len(queries) == 0:
await event_emitter(
{
@@ -384,6 +427,7 @@ async def chat_web_search_handler(
"name": ", ".join(queries),
"type": "web_search",
"urls": results["filenames"],
"queries": queries,
}
)
elif results.get("docs"):
@@ -395,6 +439,7 @@ async def chat_web_search_handler(
"name": ", ".join(queries),
"type": "web_search",
"urls": results["filenames"],
"queries": queries,
}
)
@@ -598,6 +643,7 @@ async def chat_completion_files_handler(
reranking_function=request.app.state.rf,
k_reranker=request.app.state.config.TOP_K_RERANKER,
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,
),
@@ -612,6 +658,12 @@ async def chat_completion_files_handler(
def apply_params_to_form_data(form_data, model):
params = form_data.pop("params", {})
custom_params = params.pop("custom_params", {})
if custom_params:
# If custom_params are provided, merge them into params
params = deep_update(params, custom_params)
if model.get("ollama"):
form_data["options"] = params
@@ -621,29 +673,10 @@ def apply_params_to_form_data(form_data, model):
if "keep_alive" in params:
form_data["keep_alive"] = params["keep_alive"]
else:
if "seed" in params and params["seed"] is not None:
form_data["seed"] = params["seed"]
if "stop" in params and params["stop"] is not None:
form_data["stop"] = params["stop"]
if "temperature" in params and params["temperature"] is not None:
form_data["temperature"] = params["temperature"]
if "max_tokens" in params and params["max_tokens"] is not None:
form_data["max_tokens"] = params["max_tokens"]
if "top_p" in params and params["top_p"] is not None:
form_data["top_p"] = params["top_p"]
if "frequency_penalty" in params and params["frequency_penalty"] is not None:
form_data["frequency_penalty"] = params["frequency_penalty"]
if "presence_penalty" in params and params["presence_penalty"] is not None:
form_data["presence_penalty"] = params["presence_penalty"]
if "reasoning_effort" in params and params["reasoning_effort"] is not None:
form_data["reasoning_effort"] = params["reasoning_effort"]
if isinstance(params, dict):
for key, value in params.items():
if value is not None:
form_data[key] = value
if "logit_bias" in params and params["logit_bias"] is not None:
try:
@@ -651,13 +684,12 @@ def apply_params_to_form_data(form_data, model):
convert_logit_bias_input_to_json(params["logit_bias"])
)
except Exception as e:
print(f"Error parsing logit_bias: {e}")
log.exception(f"Error parsing logit_bias: {e}")
return form_data
async def process_chat_payload(request, form_data, user, metadata, model):
form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}")
@@ -749,9 +781,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise e
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
for filter_id in get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
]
form_data, flags = await process_filter_functions(
@@ -766,6 +801,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
features = form_data.pop("features", None)
if features:
if "memory" in features and features["memory"]:
form_data = await chat_memory_handler(
request, form_data, extra_params, user
)
if "web_search" in features and features["web_search"]:
form_data = await chat_web_search_handler(
request, form_data, extra_params, user
@@ -868,6 +908,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for doc_context, doc_meta in zip(
source["document"], source["metadata"]
):
source_name = source.get("source", {}).get("name", None)
citation_id = (
doc_meta.get("source", None)
or source.get("source", {}).get("id", None)
@@ -875,7 +916,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
if citation_id not in citation_idx:
citation_idx[citation_id] = len(citation_idx) + 1
context_string += f'<source id="{citation_idx[citation_id]}">{doc_context}</source>\n'
context_string += (
f'<source id="{citation_idx[citation_id]}"'
+ (f' name="{source_name}"' if source_name else "")
+ f">{doc_context}</source>\n"
)
context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"])
@@ -942,21 +987,38 @@ async def process_chat_response(
message = message_map.get(metadata["message_id"]) if message_map else None
if message:
messages = get_message_list(message_map, message.get("id"))
message_list = get_message_list(message_map, metadata["message_id"])
# Remove reasoning details and files from the messages.
# Remove details tags and files from the messages.
# as get_message_list creates a new list, it does not affect
# the original messages outside of this handler
for message in messages:
message["content"] = re.sub(
r"<details\s+type=\"reasoning\"[^>]*>.*?<\/details>",
"",
message["content"],
flags=re.S,
).strip()
if message.get("files"):
message["files"] = []
messages = []
for message in message_list:
content = message.get("content", "")
if isinstance(content, list):
for item in content:
if item.get("type") == "text":
content = item["text"]
break
if isinstance(content, str):
content = re.sub(
r"<details\b[^>]*>.*?<\/details>|!\[.*?\]\(.*?\)",
"",
content,
flags=re.S | re.I,
).strip()
messages.append(
{
**message,
"role": message.get(
"role", "assistant"
), # Safe fallback for missing role
"content": content,
}
)
if tasks and messages:
if TASKS.TITLE_GENERATION in tasks:
@@ -1121,6 +1183,7 @@ async def process_chat_response(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
@@ -1143,8 +1206,34 @@ async def process_chat_response(
await background_tasks_handler()
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response = {
**extra_response,
**response,
}
return response
else:
if events and isinstance(events, list) and isinstance(response, dict):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response = {
**extra_response,
**response,
}
return response
# Non standard response
@@ -1169,7 +1258,9 @@ async def process_chat_response(
}
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(model)
for filter_id in get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
]
# Streaming response
+15 -6
View File
@@ -34,11 +34,15 @@ def get_message_list(messages, message_id):
:return: List of ordered messages starting from the root to the given message
"""
# Handle case where messages is None
if not messages:
return [] # Return empty list instead of None to prevent iteration errors
# Find the message by its id
current_message = messages.get(message_id)
if not current_message:
return None
return [] # Return empty list instead of None to prevent iteration errors
# Reconstruct the chain by following the parentId links
message_list = []
@@ -47,7 +51,7 @@ def get_message_list(messages, message_id):
message_list.insert(
0, current_message
) # Insert the message at the beginning of the list
parent_id = current_message["parentId"]
parent_id = current_message.get("parentId") # Use .get() for safety
current_message = messages.get(parent_id) if parent_id else None
return message_list
@@ -70,12 +74,12 @@ def get_last_user_message_item(messages: list[dict]) -> Optional[dict]:
def get_content_from_message(message: dict) -> Optional[str]:
if isinstance(message["content"], list):
if isinstance(message.get("content"), list):
for item in message["content"]:
if item["type"] == "text":
return item["text"]
else:
return message["content"]
return message.get("content")
return None
@@ -130,7 +134,9 @@ def prepend_to_first_user_message_content(
return messages
def add_or_update_system_message(content: str, messages: list[dict]):
def add_or_update_system_message(
content: str, messages: list[dict], append: bool = False
):
"""
Adds a new system message at the beginning of the messages list
or updates the existing system message at the beginning.
@@ -141,7 +147,10 @@ def add_or_update_system_message(content: str, messages: list[dict]):
"""
if messages and messages[0].get("role") == "system":
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
if append:
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
else:
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
else:
# Insert at the beginning
messages.insert(0, {"role": "system", "content": content})
+105 -34
View File
@@ -1,5 +1,6 @@
import time
import logging
import asyncio
import sys
from aiocache import cached
@@ -13,7 +14,10 @@ from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import has_access
@@ -30,34 +34,46 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def fetch_ollama_models(request: Request, user: UserModel = None):
raw_ollama_models = await ollama.get_all_models(request, user=user)
return [
{
"id": model["model"],
"name": model["name"],
"object": "model",
"created": int(time.time()),
"owned_by": "ollama",
"ollama": model,
"connection_type": model.get("connection_type", "local"),
"tags": model.get("tags", []),
}
for model in raw_ollama_models["models"]
]
async def fetch_openai_models(request: Request, user: UserModel = None):
openai_response = await openai.get_all_models(request, user=user)
return openai_response["data"]
async def get_all_base_models(request: Request, user: UserModel = None):
function_models = []
openai_models = []
ollama_models = []
openai_task = (
fetch_openai_models(request, user)
if request.app.state.config.ENABLE_OPENAI_API
else asyncio.sleep(0, result=[])
)
ollama_task = (
fetch_ollama_models(request, user)
if request.app.state.config.ENABLE_OLLAMA_API
else asyncio.sleep(0, result=[])
)
function_task = get_function_models(request)
if request.app.state.config.ENABLE_OPENAI_API:
openai_models = await openai.get_all_models(request, user=user)
openai_models = openai_models["data"]
openai_models, ollama_models, function_models = await asyncio.gather(
openai_task, ollama_task, function_task
)
if request.app.state.config.ENABLE_OLLAMA_API:
ollama_models = await ollama.get_all_models(request, user=user)
ollama_models = [
{
"id": model["model"],
"name": model["name"],
"object": "model",
"created": int(time.time()),
"owned_by": "ollama",
"ollama": model,
"tags": model.get("tags", []),
}
for model in ollama_models["models"]
]
function_models = await get_function_models(request)
models = function_models + openai_models + ollama_models
return models
return function_models + openai_models + ollama_models
async def get_all_models(request, user: UserModel = None):
@@ -110,6 +126,14 @@ async def get_all_models(request, user: UserModel = None):
for function in Functions.get_functions_by_type("action", active_only=True)
]
global_filter_ids = [
function.id for function in Functions.get_global_filter_functions()
]
enabled_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
custom_models = Models.get_all_models()
for custom_model in custom_models:
if custom_model.base_model_id is None:
@@ -125,13 +149,20 @@ async def get_all_models(request, user: UserModel = None):
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
# Set action_ids and filter_ids
action_ids = []
filter_ids = []
if "info" in model and "meta" in model["info"]:
action_ids.extend(
model["info"]["meta"].get("actionIds", [])
)
filter_ids.extend(
model["info"]["meta"].get("filterIds", [])
)
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
else:
models.remove(model)
@@ -140,7 +171,9 @@ async def get_all_models(request, user: UserModel = None):
):
owned_by = "openai"
pipe = None
action_ids = []
filter_ids = []
for model in models:
if (
@@ -154,9 +187,13 @@ async def get_all_models(request, user: UserModel = None):
if custom_model.meta:
meta = custom_model.meta.model_dump()
if "actionIds" in meta:
action_ids.extend(meta["actionIds"])
if "filterIds" in meta:
filter_ids.extend(meta["filterIds"])
models.append(
{
"id": f"{custom_model.id}",
@@ -168,6 +205,7 @@ async def get_all_models(request, user: UserModel = None):
"preset": True,
**({"pipe": pipe} if pipe is not None else {}),
"action_ids": action_ids,
"filter_ids": filter_ids,
}
)
@@ -181,8 +219,11 @@ async def get_all_models(request, user: UserModel = None):
"id": f"{function.id}.{action['id']}",
"name": action.get("name", f"{function.name} ({action['id']})"),
"description": function.meta.description,
"icon_url": action.get(
"icon_url", function.meta.manifest.get("icon_url", None)
"icon": action.get(
"icon_url",
function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
),
}
for action in actions
@@ -193,16 +234,27 @@ async def get_all_models(request, user: UserModel = None):
"id": function.id,
"name": function.name,
"description": function.meta.description,
"icon_url": function.meta.manifest.get("icon_url", None),
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
}
]
# Process filter_ids to get the filters
def get_filter_items_from_module(function, module):
return [
{
"id": function.id,
"name": function.name,
"description": function.meta.description,
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
}
]
def get_function_module_by_id(function_id):
if function_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[function_id]
else:
function_module, _, _ = load_function_module_by_id(function_id)
request.app.state.FUNCTIONS[function_id] = function_module
function_module, _, _ = get_function_module_from_cache(request, function_id)
return function_module
for model in models:
@@ -211,6 +263,11 @@ async def get_all_models(request, user: UserModel = None):
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
if action_id in enabled_action_ids
]
filter_ids = [
filter_id
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
if filter_id in enabled_filter_ids
]
model["actions"] = []
for action_id in action_ids:
@@ -222,6 +279,20 @@ async def get_all_models(request, user: UserModel = None):
model["actions"].extend(
get_action_items_from_module(action_function, function_module)
)
model["filters"] = []
for filter_id in filter_ids:
filter_function = Functions.get_function_by_id(filter_id)
if filter_function is None:
raise Exception(f"Filter not found: {filter_id}")
function_module = get_function_module_by_id(filter_id)
if getattr(function_module, "toggle", None):
model["filters"].extend(
get_filter_items_from_module(filter_function, function_module)
)
log.debug(f"get_all_models() returned {len(models)} models")
request.app.state.MODELS = {model["id"]: model for model in models}
+14 -4
View File
@@ -41,6 +41,7 @@ from open_webui.config import (
)
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@@ -305,8 +306,10 @@ class OAuthManager:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with session.get(picture_url, **get_kwargs) as resp:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
picture_url, **get_kwargs, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.ok:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(picture).decode(
@@ -371,7 +374,9 @@ class OAuthManager:
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails", headers=headers
"https://api.github.com/user/emails",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
emails = await resp.json()
@@ -531,5 +536,10 @@ class OAuthManager:
secure=WEBUI_AUTH_COOKIE_SECURE,
)
# Redirect back to the frontend with the JWT token
redirect_url = f"{request.base_url}auth#token={jwt_token}"
redirect_base_url = request.app.state.config.WEBUI_URL or request.base_url
if redirect_base_url.endswith("/"):
redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth#token={jwt_token}"
return RedirectResponse(url=redirect_url, headers=response.headers)
+20 -3
View File
@@ -1,5 +1,6 @@
from open_webui.utils.task import prompt_template, prompt_variables_template
from open_webui.utils.misc import (
deep_update,
add_or_update_system_message,
)
@@ -45,18 +46,29 @@ def apply_model_params_to_body(
if not params:
return form_data
for key, cast_func in mappings.items():
if (value := params.get(key)) is not None:
form_data[key] = cast_func(value)
for key, value in params.items():
if value is not None:
if key in mappings:
cast_func = mappings[key]
if isinstance(cast_func, Callable):
form_data[key] = cast_func(value)
else:
form_data[key] = value
return form_data
# inplace function: form_data is modified
def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
custom_params = params.pop("custom_params", {})
if custom_params:
# If there are custom parameters, we need to apply them first
params = deep_update(params, custom_params)
mappings = {
"temperature": float,
"top_p": float,
"min_p": float,
"max_tokens": int,
"frequency_penalty": float,
"presence_penalty": float,
@@ -70,6 +82,11 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
custom_params = params.pop("custom_params", {})
if custom_params:
# If there are custom parameters, we need to apply them first
params = deep_update(params, custom_params)
# Convert OpenAI parameter names to Ollama parameter names if needed.
name_differences = {
"max_tokens": "num_predict",
+57 -1
View File
@@ -115,7 +115,7 @@ def load_tool_module_by_id(tool_id, content=None):
os.unlink(temp_file.name)
def load_function_module_by_id(function_id, content=None):
def load_function_module_by_id(function_id: str, content: str | None = None):
if content is None:
function = Functions.get_function_by_id(function_id)
if not function:
@@ -166,6 +166,62 @@ def load_function_module_by_id(function_id, content=None):
os.unlink(temp_file.name)
def get_function_module_from_cache(request, function_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
# This is useful for hooks like "inlet" or "outlet" where the content might change
# and we want to ensure the latest content is used.
function = Functions.get_function_by_id(function_id)
if not function:
raise Exception(f"Function not found: {function_id}")
content = function.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the function content in the database
Functions.update_function_by_id(function_id, {"content": content})
if (
hasattr(request.app.state, "FUNCTION_CONTENTS")
and function_id in request.app.state.FUNCTION_CONTENTS
) and (
hasattr(request.app.state, "FUNCTIONS")
and function_id in request.app.state.FUNCTIONS
):
if request.app.state.FUNCTION_CONTENTS[function_id] == content:
return request.app.state.FUNCTIONS[function_id], None, None
function_module, function_type, frontmatter = load_function_module_by_id(
function_id, content
)
else:
# Load from cache (e.g. "stream" hook)
# This is useful for performance reasons
if (
hasattr(request.app.state, "FUNCTIONS")
and function_id in request.app.state.FUNCTIONS
):
return request.app.state.FUNCTIONS[function_id], None, None
function_module, function_type, frontmatter = load_function_module_by_id(
function_id
)
if not hasattr(request.app.state, "FUNCTIONS"):
request.app.state.FUNCTIONS = {}
if not hasattr(request.app.state, "FUNCTION_CONTENTS"):
request.app.state.FUNCTION_CONTENTS = {}
request.app.state.FUNCTIONS[function_id] = function_module
request.app.state.FUNCTION_CONTENTS[function_id] = content
return function_module, function_type, frontmatter
def install_frontmatter_requirements(requirements: str):
if requirements:
try:
+1 -1
View File
@@ -22,7 +22,7 @@ def get_task_model_id(
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if models[task_model_id].get("owned_by") == "ollama":
if models[task_model_id].get("connection_type") == "local":
if task_model and task_model in models:
task_model_id = task_model
else:
+34 -10
View File
@@ -37,6 +37,7 @@ from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.env import (
SRC_LOG_LEVELS,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
)
@@ -44,6 +45,7 @@ from open_webui.env import (
import copy
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
def get_async_tool_function_and_apply_extra_params(
@@ -158,7 +160,7 @@ def get_tools(
# TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values():
if val["type"] == "str":
if val.get("type") == "str":
val["type"] = "string"
# Remove internal reserved parameters (e.g. __id__, __user__)
@@ -477,7 +479,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
"specs": convert_openapi_to_tool_payload(res),
}
print("Fetched data:", data)
log.info("Fetched data:", data)
return data
@@ -488,8 +490,19 @@ async def get_tool_servers_data(
server_entries = []
for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"):
url_path = server.get("path", "openapi.json")
full_url = f"{server.get('url')}/{url_path}"
# 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")
if "://" in openapi_path:
# If it contains "://", it's a full URL
full_url = openapi_path
else:
if not openapi_path.startswith("/"):
# Ensure the path starts with a slash
openapi_path = f"/{openapi_path}"
full_url = f"{server.get('url')}{openapi_path}"
info = server.get("info", {})
auth_type = server.get("auth_type", "bearer")
token = None
@@ -498,26 +511,37 @@ async def get_tool_servers_data(
token = server.get("key", "")
elif auth_type == "session":
token = session_token
server_entries.append((idx, server, full_url, token))
server_entries.append((idx, server, full_url, info, token))
# Create async tasks to fetch data
tasks = [get_tool_server_data(token, url) for (_, _, url, token) in server_entries]
tasks = [
get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries
]
# Execute tasks concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True)
# Build final results with index and server metadata
results = []
for (idx, server, url, _), response in zip(server_entries, responses):
for (idx, server, url, info, _), response in zip(server_entries, responses):
if isinstance(response, Exception):
print(f"Failed to connect to {url} OpenAPI tool server")
log.error(f"Failed to connect to {url} OpenAPI tool server")
continue
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
if "name" in info:
openapi_data["info"]["title"] = info.get("name", "Tool Server")
if "description" in info:
openapi_data["info"]["description"] = info.get("description", "")
results.append(
{
"idx": idx,
"url": server.get("url"),
"openapi": response.get("openapi"),
"openapi": openapi_data,
"info": response.get("info"),
"specs": response.get("specs"),
}
@@ -620,5 +644,5 @@ async def execute_tool_server(
except Exception as err:
error = str(err)
print("API Request Error:", error)
log.exception("API Request Error:", error)
return {"error": error}
+6 -3
View File
@@ -12,10 +12,12 @@ aiohttp==3.11.11
async-timeout
aiocache
aiofiles
starlette-compress==1.6.0
sqlalchemy==2.0.38
alembic==1.14.0
peewee==3.17.9
peewee==3.18.1
peewee-migrate==1.12.2
psycopg2-binary==2.9.9
pgvector==0.4.0
@@ -37,7 +39,8 @@ asgiref==3.8.1
# AI libraries
openai
anthropic
google-generativeai==0.8.4
google-genai==1.15.0
google-generativeai==0.8.5
tiktoken
langchain==0.3.24
@@ -98,7 +101,7 @@ pytube==15.0.0
extract_msg
pydub
duckduckgo-search~=8.0.0
duckduckgo-search==8.0.2
## Google Drive
google-api-python-client
+76 -11
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.6.8",
"version": "0.6.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.8",
"version": "0.6.12",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@@ -22,6 +22,10 @@
"@tiptap/extension-code-block-lowlight": "^2.11.9",
"@tiptap/extension-highlight": "^2.10.0",
"@tiptap/extension-placeholder": "^2.10.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-typography": "^2.10.0",
"@tiptap/pm": "^2.11.7",
"@tiptap/starter-kit": "^2.10.0",
@@ -62,6 +66,7 @@
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
"socket.io-client": "^4.2.0",
@@ -69,6 +74,7 @@
"svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.3.0",
"uuid": "^9.0.1",
"vite-plugin-static-copy": "^2.2.0",
@@ -3173,6 +3179,59 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.12.0.tgz",
"integrity": "sha512-tT3IbbBal0vPQ1Bc/3Xl+tmqqZQCYWxnycBPl/WZBqhd57DWzfJqRPESwCGUIJgjOtTnipy/ulvj0FxHi1j9JA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.12.0.tgz",
"integrity": "sha512-8i35uCkmkSiQxMiZ+DLgT/wj24P5U/Zo3jr1e0tMAAMG7sRO1MljjLmkpV8WCdBo0xoRqzkz4J7Nkq+DtzZv9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.12.0.tgz",
"integrity": "sha512-gRKEsy13KKLpg9RxyPeUGqh4BRFSJ2Bc2KQP1ldhef6CPRYHCbGycxXCVQ5aAb7Mhpo54L+AAkmAv1iMHUTflw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.12.0.tgz",
"integrity": "sha512-AEW/Zl9V0IoaYDBLMhF5lVl0xgoIJs3IuKCsIYxGDlxBfTVFC6PfQzvuy296CMjO5ZcZ0xalVipPV9ggsMRD+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
@@ -9809,16 +9868,16 @@
}
},
"node_modules/prosemirror-tables": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.4.tgz",
"integrity": "sha512-TkDY3Gw52gRFRfRn2f4wJv5WOgAOXLJA2CQJYIJ5+kdFbfj3acR4JUW6LX2e1hiEBiUwvEhzH5a3cZ5YSztpIA==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
"integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-model": "^1.25.0",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.2"
"prosemirror-transform": "^1.10.3",
"prosemirror-view": "^1.39.1"
}
},
"node_modules/prosemirror-trailing-node": {
@@ -9837,9 +9896,9 @@
}
},
"node_modules/prosemirror-transform": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz",
"integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==",
"version": "1.10.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
"integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
@@ -11808,6 +11867,12 @@
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+8 -1
View File
@@ -1,11 +1,12 @@
{
"name": "open-webui",
"version": "0.6.8",
"version": "0.6.12",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build",
"build:watch": "npm run pyodide:fetch && vite build --watch",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -65,6 +66,10 @@
"@tiptap/extension-code-block-lowlight": "^2.11.9",
"@tiptap/extension-highlight": "^2.10.0",
"@tiptap/extension-placeholder": "^2.10.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-typography": "^2.10.0",
"@tiptap/pm": "^2.11.7",
"@tiptap/starter-kit": "^2.10.0",
@@ -105,6 +110,7 @@
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
"socket.io-client": "^4.2.0",
@@ -112,6 +118,7 @@
"svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.3.0",
"uuid": "^9.0.1",
"vite-plugin-static-copy": "^2.2.0",
+6 -3
View File
@@ -21,9 +21,11 @@ dependencies = [
"aiocache",
"aiofiles",
"starlette-compress==1.6.0",
"sqlalchemy==2.0.38",
"alembic==1.14.0",
"peewee==3.17.9",
"peewee==3.18.1",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.4.0",
@@ -45,7 +47,8 @@ dependencies = [
"openai",
"anthropic",
"google-generativeai==0.8.4",
"google-genai==1.15.0",
"google-generativeai==0.8.5",
"tiktoken",
"langchain==0.3.24",
@@ -105,7 +108,7 @@ dependencies = [
"extract_msg",
"pydub",
"duckduckgo-search~=8.0.0",
"duckduckgo-search==8.0.2",
"google-api-python-client",
"google-auth-httplib2",
+39 -5
View File
@@ -81,7 +81,7 @@ textarea::placeholder {
}
.font-primary {
font-family: 'Archivo', sans-serif;
font-family: 'Archivo', 'Vazirmatn', sans-serif;
}
.drag-region {
@@ -103,7 +103,7 @@ li p {
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
background-color: rgba(236, 236, 236, 0.8);
background-color: rgba(215, 215, 215, 0.8);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
@@ -111,12 +111,12 @@ li p {
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
background-color: rgba(42, 42, 42, 0.8); /* Darker color for dark theme */
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
}
::-webkit-scrollbar {
height: 0.8rem;
height: 0.6rem;
width: 0.4rem;
}
@@ -314,12 +314,20 @@ input[type='number'] {
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
/* Below color is from tailwind, and has the proper contrast
text-gray-600 from: https://tailwindcss.com/docs/color */
color: #676767;
pointer-events: none;
@apply line-clamp-1 absolute;
}
@media (prefers-color-scheme: dark) {
.ProseMirror p.is-editor-empty:first-child::before {
color: #757575;
}
}
.ai-autocompletion::after {
color: #a0a0a0;
@@ -404,3 +412,29 @@ input[type='number'] {
.hljs-strong {
font-weight: 700;
}
/* Table styling for tiptap editors */
.tiptap table {
@apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full;
}
.tiptap thead {
@apply text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none;
}
.tiptap th,
.tiptap td {
@apply px-3 py-1.5 border border-gray-100 dark:border-gray-850;
}
.tiptap th {
@apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
}
.tiptap td {
@apply text-gray-900 dark:text-white w-max;
}
.tiptap tr {
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
}
+10 -7
View File
@@ -15,7 +15,7 @@ export const getAudioConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -52,7 +52,7 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -64,9 +64,12 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm
return res;
};
export const transcribeAudio = async (token: string, file: File) => {
export const transcribeAudio = async (token: string, file: File, language?: string) => {
const data = new FormData();
data.append('file', file);
if (language) {
data.append('language', language);
}
let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, {
@@ -83,7 +86,7 @@ export const transcribeAudio = async (token: string, file: File) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -120,7 +123,7 @@ export const synthesizeOpenAISpeech = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -152,7 +155,7 @@ export const getModels = async (token: string = ''): Promise<AvailableModelsResp
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -180,7 +183,7 @@ export const getVoices = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+25 -24
View File
@@ -15,7 +15,7 @@ export const getAdminDetails = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -42,7 +42,7 @@ export const getAdminConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -70,7 +70,7 @@ export const updateAdminConfig = async (token: string, body: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -98,7 +98,7 @@ export const getSessionUser = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -129,7 +129,7 @@ export const ldapUserSignIn = async (user: string, password: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
@@ -157,7 +157,7 @@ export const getLdapConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -187,7 +187,7 @@ export const updateLdapConfig = async (token: string = '', enable_ldap: boolean)
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -214,7 +214,7 @@ export const getLdapServer = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -242,7 +242,7 @@ export const updateLdapServer = async (token: string = '', body: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -273,7 +273,7 @@ export const userSignIn = async (email: string, password: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
@@ -312,7 +312,7 @@ export const userSignUp = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -339,7 +339,7 @@ export const userSignOut = async () => {
return res;
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -347,6 +347,7 @@ export const userSignOut = async () => {
if (error) {
throw error;
}
return res;
};
export const addUser = async (
@@ -378,7 +379,7 @@ export const addUser = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -409,7 +410,7 @@ export const updateUserProfile = async (token: string, name: string, profileImag
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -440,7 +441,7 @@ export const updateUserPassword = async (token: string, password: string, newPas
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -467,7 +468,7 @@ export const getSignUpEnabledStatus = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -494,7 +495,7 @@ export const getDefaultUserRole = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -524,7 +525,7 @@ export const updateDefaultUserRole = async (token: string, role: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -551,7 +552,7 @@ export const toggleSignUpEnabledStatus = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -578,7 +579,7 @@ export const getJWTExpiresDuration = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -608,7 +609,7 @@ export const updateJWTExpiresDuration = async (token: string, duration: string)
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -635,7 +636,7 @@ export const createAPIKey = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -660,7 +661,7 @@ export const getAPIKey = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -685,7 +686,7 @@ export const deleteAPIKey = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
+12 -12
View File
@@ -28,7 +28,7 @@ export const createNewChannel = async (token: string = '', channel: ChannelForm)
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -59,7 +59,7 @@ export const getChannels = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -90,7 +90,7 @@ export const getChannelById = async (token: string = '', channel_id: string) =>
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -126,7 +126,7 @@ export const updateChannelById = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -157,7 +157,7 @@ export const deleteChannelById = async (token: string = '', channel_id: string)
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -196,7 +196,7 @@ export const getChannelMessages = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -236,7 +236,7 @@ export const getChannelThreadMessages = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -275,7 +275,7 @@ export const sendMessage = async (token: string = '', channel_id: string, messag
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -315,7 +315,7 @@ export const updateMessage = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -355,7 +355,7 @@ export const addReaction = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -395,7 +395,7 @@ export const removeReaction = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -429,7 +429,7 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+101 -63
View File
@@ -21,7 +21,7 @@ export const createNewChat = async (token: string, chat: object) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -61,7 +61,7 @@ export const importChat = async (
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -97,7 +97,7 @@ export const getChatList = async (token: string = '', page: number | null = null
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -111,10 +111,79 @@ export const getChatList = async (token: string = '', page: number | null = null
}));
};
export const getChatListByUserId = async (token: string = '', userId: string) => {
export const getChatListByUserId = async (
token: string = '',
userId: string,
page: number = 1,
filter?: object
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/list/user/${userId}`, {
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(
`${WEBUI_API_BASE_URL}/chats/list/user/${userId}?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getArchivedChatList = async (
token: string = '',
page: number = 1,
filter?: object
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -131,7 +200,7 @@ export const getChatListByUserId = async (token: string = '', userId: string) =>
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -145,37 +214,6 @@ export const getChatListByUserId = async (token: string = '', userId: string) =>
}));
};
export const getArchivedChatList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllChats = async (token: string) => {
let error = null;
@@ -196,7 +234,7 @@ export const getAllChats = async (token: string) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -231,7 +269,7 @@ export const getChatListBySearchText = async (token: string, text: string, page:
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -265,7 +303,7 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -296,7 +334,7 @@ export const getAllArchivedChats = async (token: string) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -327,7 +365,7 @@ export const getAllUserChats = async (token: string) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -358,7 +396,7 @@ export const getAllTags = async (token: string) => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -389,7 +427,7 @@ export const getPinnedChatList = async (token: string = '') => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -426,7 +464,7 @@ export const getChatListByTagName = async (token: string = '', tagName: string)
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -461,7 +499,7 @@ export const getChatById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -493,7 +531,7 @@ export const getChatByShareId = async (token: string, share_id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -531,7 +569,7 @@ export const getChatPinnedStatusById = async (token: string, id: string) => {
error = err;
}
console.log(err);
console.error(err);
return null;
});
@@ -569,7 +607,7 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => {
error = err;
}
console.log(err);
console.error(err);
return null;
});
@@ -610,7 +648,7 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
error = err;
}
console.log(err);
console.error(err);
return null;
});
@@ -648,7 +686,7 @@ export const cloneSharedChatById = async (token: string, id: string) => {
error = err;
}
console.log(err);
console.error(err);
return null;
});
@@ -680,7 +718,7 @@ export const shareChatById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -715,7 +753,7 @@ export const updateChatFolderIdById = async (token: string, id: string, folderId
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -747,7 +785,7 @@ export const archiveChatById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -779,7 +817,7 @@ export const deleteSharedChatById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -814,7 +852,7 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -846,7 +884,7 @@ export const deleteChatById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -878,7 +916,7 @@ export const getTagsById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -912,7 +950,7 @@ export const addTagById = async (token: string, id: string, tagName: string) =>
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -947,7 +985,7 @@ export const deleteTagById = async (token: string, id: string, tagName: string)
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -978,7 +1016,7 @@ export const deleteTagsById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -1010,7 +1048,7 @@ export const deleteAllChats = async (token: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -1042,7 +1080,7 @@ export const archiveAllChats = async (token: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+14 -14
View File
@@ -19,7 +19,7 @@ export const importConfig = async (token: string, config) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -46,7 +46,7 @@ export const exportConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -73,7 +73,7 @@ export const getDirectConnectionsConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -103,7 +103,7 @@ export const setDirectConnectionsConfig = async (token: string, config: object)
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -130,7 +130,7 @@ export const getToolServerConnections = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -160,7 +160,7 @@ export const setToolServerConnections = async (token: string, connections: objec
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -190,7 +190,7 @@ export const verifyToolServerConnection = async (token: string, connection: obje
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -217,7 +217,7 @@ export const getCodeExecutionConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -247,7 +247,7 @@ export const setCodeExecutionConfig = async (token: string, config: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -274,7 +274,7 @@ export const getModelsConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -304,7 +304,7 @@ export const setModelsConfig = async (token: string, config: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -334,7 +334,7 @@ export const setDefaultPromptSuggestions = async (token: string, promptSuggestio
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -361,7 +361,7 @@ export const getBanners = async (token: string): Promise<Banner[]> => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -391,7 +391,7 @@ export const setBanners = async (token: string, banners: Banner[]) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
+8 -8
View File
@@ -20,7 +20,7 @@ export const getConfig = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -51,7 +51,7 @@ export const updateConfig = async (token: string, config: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -82,7 +82,7 @@ export const getAllFeedbacks = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -113,7 +113,7 @@ export const exportAllFeedbacks = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -144,7 +144,7 @@ export const createNewFeedback = async (token: string, feedback: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -175,7 +175,7 @@ export const getFeedbackById = async (token: string, feedbackId: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -206,7 +206,7 @@ export const updateFeedbackById = async (token: string, feedbackId: string, feed
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -234,7 +234,7 @@ export const deleteFeedbackById = async (token: string, feedbackId: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+12 -8
View File
@@ -1,8 +1,12 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const uploadFile = async (token: string, file: File) => {
export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
const data = new FormData();
data.append('file', file);
if (metadata) {
data.append('metadata', JSON.stringify(metadata));
}
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, {
@@ -19,7 +23,7 @@ export const uploadFile = async (token: string, file: File) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -76,7 +80,7 @@ export const getFiles = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -107,7 +111,7 @@ export const getFileById = async (token: string, id: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -141,7 +145,7 @@ export const updateFileDataContentById = async (token: string, id: string, conte
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -168,7 +172,7 @@ export const getFileContentById = async (id: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -200,7 +204,7 @@ export const deleteFileById = async (token: string, id: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -231,7 +235,7 @@ export const deleteAllFiles = async (token: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+7 -7
View File
@@ -50,7 +50,7 @@ export const getFolders = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -81,7 +81,7 @@ export const getFolderById = async (token: string, id: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -115,7 +115,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -153,7 +153,7 @@ export const updateFolderIsExpandedById = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -187,7 +187,7 @@ export const updateFolderParentIdById = async (token: string, id: string, parent
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -226,7 +226,7 @@ export const updateFolderItemsById = async (token: string, id: string, items: Fo
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -257,7 +257,7 @@ export const deleteFolderById = async (token: string, id: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+48 -14
View File
@@ -20,7 +20,7 @@ export const createNewFunction = async (token: string, func: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -51,7 +51,41 @@ export const getFunctions = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const loadFunctionByUrl = async (token: string = '', url: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/functions/load/url`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
url
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
@@ -82,7 +116,7 @@ export const exportFunctions = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -114,7 +148,7 @@ export const getFunctionById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -149,7 +183,7 @@ export const updateFunctionById = async (token: string, id: string, func: object
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -181,7 +215,7 @@ export const deleteFunctionById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -213,7 +247,7 @@ export const toggleFunctionById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -245,7 +279,7 @@ export const toggleGlobalById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -277,7 +311,7 @@ export const getFunctionValvesById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -309,7 +343,7 @@ export const getFunctionValvesSpecById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -344,7 +378,7 @@ export const updateFunctionValvesById = async (token: string, id: string, valves
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -376,7 +410,7 @@ export const getUserValvesById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -408,7 +442,7 @@ export const getUserValvesSpecById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -443,7 +477,7 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+5 -5
View File
@@ -20,7 +20,7 @@ export const createNewGroup = async (token: string, group: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -51,7 +51,7 @@ export const getGroups = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -83,7 +83,7 @@ export const getGroupById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -118,7 +118,7 @@ export const updateGroupById = async (token: string, id: string, group: object)
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -150,7 +150,7 @@ export const deleteGroupById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+7 -7
View File
@@ -16,7 +16,7 @@ export const getConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -51,7 +51,7 @@ export const updateConfig = async (token: string = '', config: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -83,7 +83,7 @@ export const verifyConfigUrl = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -115,7 +115,7 @@ export const getImageGenerationConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -148,7 +148,7 @@ export const updateImageGenerationConfig = async (token: string = '', config: ob
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -180,7 +180,7 @@ export const getImageGenerationModels = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -215,7 +215,7 @@ export const imageGenerations = async (token: string = '', prompt: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
+39 -35
View File
@@ -25,7 +25,7 @@ export const getModels = async (
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -173,7 +173,7 @@ export const chatCompleted = async (token: string, body: ChatCompletedForm) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -212,7 +212,7 @@ export const chatAction = async (token: string, action_id: string, body: ChatAct
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -244,7 +244,7 @@ export const stopTask = async (token: string, id: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -276,7 +276,7 @@ export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -315,7 +315,7 @@ export const getToolServerData = async (token: string, url: string) => {
}
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -346,11 +346,15 @@ export const getToolServersData = async (i18n, servers: object[]) => {
.map(async (server) => {
const data = await getToolServerData(
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
server?.url + '/' + (server?.path ?? 'openapi.json')
(server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => {
toast.error(
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: server?.url + '/' + (server?.path ?? 'openapi.json')
URL: (server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
})
);
return null;
@@ -491,7 +495,7 @@ export const getTaskConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -520,7 +524,7 @@ export const updateTaskConfig = async (token: string, config: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -562,7 +566,7 @@ export const generateTitle = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -634,7 +638,7 @@ export const generateTags = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -706,7 +710,7 @@ export const generateEmoji = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -756,7 +760,7 @@ export const generateQueries = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -828,7 +832,7 @@ export const generateAutoCompletion = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -892,7 +896,7 @@ export const generateMoACompletion = async (
stream: true
})
}).catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -920,7 +924,7 @@ export const getPipelinesList = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -954,7 +958,7 @@ export const uploadPipeline = async (token: string, file: File, urlIdx: string)
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -990,7 +994,7 @@ export const downloadPipeline = async (token: string, url: string, urlIdx: strin
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -1026,7 +1030,7 @@ export const deletePipeline = async (token: string, id: string, urlIdx: string)
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -1063,7 +1067,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1100,7 +1104,7 @@ export const getPipelineValves = async (token: string, pipeline_id: string, urlI
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1136,7 +1140,7 @@ export const getPipelineValvesSpec = async (token: string, pipeline_id: string,
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1178,7 +1182,7 @@ export const updatePipelineValves = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
@@ -1210,7 +1214,7 @@ export const getBackendConfig = async () => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1236,7 +1240,7 @@ export const getChangelog = async () => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1263,7 +1267,7 @@ export const getVersionUpdates = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1290,7 +1294,7 @@ export const getModelFilterConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1325,7 +1329,7 @@ export const updateModelFilterConfig = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1352,7 +1356,7 @@ export const getWebhookUrl = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1382,7 +1386,7 @@ export const updateWebhookUrl = async (token: string, url: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1409,7 +1413,7 @@ export const getCommunitySharingEnabledStatus = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1436,7 +1440,7 @@ export const toggleCommunitySharingEnabledStatus = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -1463,7 +1467,7 @@ export const getModelConfig = async (token: string): Promise<GlobalModelConfig>
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -1511,7 +1515,7 @@ export const updateModelConfig = async (token: string, config: GlobalModelConfig
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
+11 -11
View File
@@ -27,7 +27,7 @@ export const createNewKnowledge = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -58,7 +58,7 @@ export const getKnowledgeBases = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -89,7 +89,7 @@ export const getKnowledgeBaseList = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -121,7 +121,7 @@ export const getKnowledgeById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -166,7 +166,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -201,7 +201,7 @@ export const addFileToKnowledgeById = async (token: string, id: string, fileId:
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -236,7 +236,7 @@ export const updateFileFromKnowledgeById = async (token: string, id: string, fil
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -271,7 +271,7 @@ export const removeFileFromKnowledgeById = async (token: string, id: string, fil
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -303,7 +303,7 @@ export const resetKnowledgeById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -335,7 +335,7 @@ export const deleteKnowledgeById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -363,7 +363,7 @@ export const reindexKnowledgeFiles = async (token: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+6 -6
View File
@@ -17,7 +17,7 @@ export const getMemories = async (token: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -48,7 +48,7 @@ export const addNewMemory = async (token: string, content: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -79,7 +79,7 @@ export const updateMemoryById = async (token: string, id: string, content: strin
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -110,7 +110,7 @@ export const queryMemory = async (token: string, content: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -142,7 +142,7 @@ export const deleteMemoryById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -174,7 +174,7 @@ export const deleteMemoriesByUserId = async (token: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+8 -8
View File
@@ -20,7 +20,7 @@ export const getModels = async (token: string = '') => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -51,7 +51,7 @@ export const getBaseModels = async (token: string = '') => {
})
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -80,7 +80,7 @@ export const createNewModel = async (token: string, model: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -115,7 +115,7 @@ export const getModelById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -150,7 +150,7 @@ export const toggleModelById = async (token: string, id: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -186,7 +186,7 @@ export const updateModelById = async (token: string, id: string, model: object)
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
@@ -221,7 +221,7 @@ export const deleteModelById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -253,7 +253,7 @@ export const deleteAllModels = async (token: string) => {
.catch((err) => {
error = err;
console.log(err);
console.error(err);
return null;
});
+5 -5
View File
@@ -28,7 +28,7 @@ export const createNewNote = async (token: string, note: NoteItem) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -59,7 +59,7 @@ export const getNotes = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -108,7 +108,7 @@ export const getNoteById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -143,7 +143,7 @@ export const updateNoteById = async (token: string, id: string, note: NoteItem)
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -175,7 +175,7 @@ export const deleteNoteById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+39 -19
View File
@@ -1,10 +1,6 @@
import { OLLAMA_API_BASE_URL } from '$lib/constants';
export const verifyOllamaConnection = async (
token: string = '',
url: string = '',
key: string = ''
) => {
export const verifyOllamaConnection = async (token: string = '', connection: dict = {}) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/verify`, {
@@ -15,8 +11,7 @@ export const verifyOllamaConnection = async (
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
...connection
})
})
.then(async (res) => {
@@ -51,7 +46,7 @@ export const getOllamaConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -92,7 +87,7 @@ export const updateOllamaConfig = async (token: string = '', config: OllamaConfi
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -124,7 +119,7 @@ export const getOllamaUrls = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -159,7 +154,7 @@ export const updateOllamaUrls = async (token: string = '', urls: string[]) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -191,7 +186,7 @@ export const getOllamaVersion = async (token: string, urlIdx?: number) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -223,7 +218,7 @@ export const getOllamaModels = async (token: string = '', urlIdx: null | number
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -268,7 +263,7 @@ export const generatePrompt = async (token: string = '', model: string, conversa
`
})
}).catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
}
@@ -360,6 +355,31 @@ export const generateChatCompletion = async (token: string = '', body: object) =
return [res, controller];
};
export const unloadModel = async (token: string, tagName: string) => {
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/unload`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName
})
}).catch((err) => {
error = err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => {
let error = null;
@@ -408,11 +428,11 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
return res.json();
})
.then((json) => {
console.log(json);
console.debug(json);
return true;
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
if ('detail' in err) {
@@ -445,7 +465,7 @@ export const pullModel = async (token: string, tagName: string, urlIdx: number |
name: tagName
})
}).catch((err) => {
console.log(err);
console.error(err);
error = err;
if ('detail' in err) {
@@ -481,7 +501,7 @@ export const downloadModel = async (
})
}
).catch((err) => {
console.log(err);
console.error(err);
error = err;
if ('detail' in err) {
@@ -512,7 +532,7 @@ export const uploadModel = async (token: string, file: File, urlIdx: string | nu
body: formData
}
).catch((err) => {
console.log(err);
console.error(err);
error = err;
if ('detail' in err) {
+12 -11
View File
@@ -16,7 +16,7 @@ export const getOpenAIConfig = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -58,7 +58,7 @@ export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfi
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -90,7 +90,7 @@ export const getOpenAIUrls = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -125,7 +125,7 @@ export const updateOpenAIUrls = async (token: string = '', urls: string[]) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -157,7 +157,7 @@ export const getOpenAIKeys = async (token: string = '') => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -192,7 +192,7 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
@@ -267,10 +267,10 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
export const verifyOpenAIConnection = async (
token: string = '',
url: string = 'https://api.openai.com/v1',
key: string = '',
connection: dict = {},
direct: boolean = false
) => {
const { url, key, config } = connection;
if (!url) {
throw 'OpenAI: URL is required';
}
@@ -309,7 +309,8 @@ export const verifyOpenAIConnection = async (
},
body: JSON.stringify({
url,
key
key,
config
})
})
.then(async (res) => {
@@ -346,7 +347,7 @@ export const chatCompletion = async (
},
body: JSON.stringify(body)
}).catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -409,7 +410,7 @@ export const synthesizeOpenAISpeech = async (
voice: speaker
})
}).catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
+6 -6
View File
@@ -28,7 +28,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -59,7 +59,7 @@ export const getPrompts = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -90,7 +90,7 @@ export const getPromptList = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -122,7 +122,7 @@ export const getPromptByCommand = async (token: string, command: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -158,7 +158,7 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) =
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -192,7 +192,7 @@ export const deletePromptByCommand = async (token: string, command: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+12 -12
View File
@@ -15,7 +15,7 @@ export const getRAGConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -77,7 +77,7 @@ export const updateRAGConfig = async (token: string, payload: RAGConfigForm) =>
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -104,7 +104,7 @@ export const getQuerySettings = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -140,7 +140,7 @@ export const updateQuerySettings = async (token: string, settings: QuerySettings
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -167,7 +167,7 @@ export const getEmbeddingConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -209,7 +209,7 @@ export const updateEmbeddingConfig = async (token: string, payload: EmbeddingMod
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -236,7 +236,7 @@ export const getRerankingConfig = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -270,7 +270,7 @@ export const updateRerankingConfig = async (token: string, payload: RerankingMod
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -313,7 +313,7 @@ export const processFile = async (
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -344,7 +344,7 @@ export const processYoutubeVideo = async (token: string, url: string) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -376,7 +376,7 @@ export const processWeb = async (token: string, collection_name: string, url: st
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -410,7 +410,7 @@ export const processWebSearch = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
+47 -13
View File
@@ -20,7 +20,41 @@ export const createNewTool = async (token: string, tool: object) => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const loadToolByUrl = async (token: string = '', url: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/load/url`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
url
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
@@ -51,7 +85,7 @@ export const getTools = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -82,7 +116,7 @@ export const getToolList = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -113,7 +147,7 @@ export const exportTools = async (token: string = '') => {
})
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -145,7 +179,7 @@ export const getToolById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -180,7 +214,7 @@ export const updateToolById = async (token: string, id: string, tool: object) =>
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -212,7 +246,7 @@ export const deleteToolById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -244,7 +278,7 @@ export const getToolValvesById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -276,7 +310,7 @@ export const getToolValvesSpecById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -311,7 +345,7 @@ export const updateToolValvesById = async (token: string, id: string, valves: ob
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -343,7 +377,7 @@ export const getUserValvesById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -375,7 +409,7 @@ export const getUserValvesSpecById = async (token: string, id: string) => {
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
@@ -410,7 +444,7 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
.catch((err) => {
error = err.detail;
console.log(err);
console.error(err);
return null;
});
+15 -15
View File
@@ -16,7 +16,7 @@ export const getUserGroups = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -43,7 +43,7 @@ export const getUserDefaultPermissions = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -73,7 +73,7 @@ export const updateUserDefaultPermissions = async (token: string, permissions: o
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -104,7 +104,7 @@ export const updateUserRole = async (token: string, id: string, role: string) =>
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -154,7 +154,7 @@ export const getUsers = async (
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -182,7 +182,7 @@ export const getAllUsers = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -208,7 +208,7 @@ export const getUserSettings = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -238,7 +238,7 @@ export const updateUserSettings = async (token: string, settings: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -265,7 +265,7 @@ export const getUserById = async (token: string, userId: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -291,7 +291,7 @@ export const getUserInfo = async (token: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -321,7 +321,7 @@ export const updateUserInfo = async (token: string, info: object) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -335,7 +335,7 @@ export const updateUserInfo = async (token: string, info: object) => {
export const getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => {
console.log(err);
console.error(err);
return null;
});
@@ -343,7 +343,7 @@ export const getAndUpdateUserLocation = async (token: string) => {
await updateUserInfo(token, { location: location });
return location;
} else {
console.log('Failed to get user location');
console.info('Failed to get user location');
return null;
}
};
@@ -363,7 +363,7 @@ export const deleteUserById = async (token: string, userId: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -403,7 +403,7 @@ export const updateUserById = async (token: string, userId: string, user: UserUp
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
+7 -7
View File
@@ -15,7 +15,7 @@ export const getGravatarUrl = async (token: string, email: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -41,7 +41,7 @@ export const executeCode = async (token: string, code: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
if (err.detail) {
@@ -75,7 +75,7 @@ export const formatPythonCode = async (token: string, code: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
if (err.detail) {
@@ -110,7 +110,7 @@ export const downloadChatAsPDF = async (token: string, title: string, messages:
return res.blob();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -136,7 +136,7 @@ export const getHTMLFromMarkdown = async (token: string, md: string) => {
return res.json();
})
.catch((err) => {
console.log(err);
console.error(err);
error = err;
return null;
});
@@ -170,7 +170,7 @@ export const downloadDatabase = async (token: string) => {
window.URL.revokeObjectURL(url);
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
@@ -206,7 +206,7 @@ export const downloadLiteLLMConfig = async (token: string) => {
window.URL.revokeObjectURL(url);
})
.catch((err) => {
console.log(err);
console.error(err);
error = err.detail;
return null;
});
+111 -10
View File
@@ -30,8 +30,17 @@
let url = '';
let key = '';
let connectionType = 'external';
let azure = false;
$: azure =
(url.includes('azure.com') || url.includes('cognitive.microsoft.com')) && !direct
? true
: false;
let prefixId = '';
let enable = true;
let apiVersion = '';
let tags = [];
let modelId = '';
@@ -40,7 +49,10 @@
let loading = false;
const verifyOllamaHandler = async () => {
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
const res = await verifyOllamaConnection(localStorage.token, {
url,
key
}).catch((error) => {
toast.error(`${error}`);
});
@@ -50,11 +62,20 @@
};
const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key, direct).catch(
(error) => {
toast.error(`${error}`);
}
);
const res = await verifyOpenAIConnection(
localStorage.token,
{
url,
key,
config: {
azure: azure,
api_version: apiVersion
}
},
direct
).catch((error) => {
toast.error(`${error}`);
});
if (res) {
toast.success($i18n.t('Server connection verified'));
@@ -85,6 +106,28 @@
return;
}
if (azure) {
if (!apiVersion) {
loading = false;
toast.error('API Version is required');
return;
}
if (!key) {
loading = false;
toast.error('Key is required');
return;
}
if (modelIds.length === 0) {
loading = false;
toast.error('Deployment names are required');
return;
}
}
// remove trailing slash from url
url = url.replace(/\/$/, '');
@@ -95,7 +138,9 @@
enable: enable,
tags: tags,
prefix_id: prefixId,
model_ids: modelIds
model_ids: modelIds,
connection_type: connectionType,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
}
};
@@ -120,6 +165,14 @@
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
modelIds = connection.config?.model_ids ?? [];
if (ollama) {
connectionType = connection.config?.connection_type ?? 'local';
} else {
connectionType = connection.config?.connection_type ?? 'external';
azure = connection.config?.azure ?? false;
apiVersion = connection.config?.api_version ?? '';
}
}
};
@@ -134,7 +187,7 @@
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
@@ -171,7 +224,31 @@
}}
>
<div class="px-1">
<div class="flex gap-2">
{#if !direct}
<div class="flex gap-2">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Connection Type')}</div>
<div class="">
<button
on:click={() => {
connectionType = connectionType === 'local' ? 'external' : 'local';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if connectionType === 'local'}
{$i18n.t('Local')}
{:else}
{$i18n.t('External')}
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-1.5">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('URL')}</div>
@@ -252,6 +329,25 @@
</div>
</div>
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('API Version')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-1.5 text-xs text-gray-500">{$i18n.t('Tags')}</div>
@@ -308,6 +404,11 @@
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
@@ -317,7 +418,7 @@
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
<div class="flex items-center">
<input
+53 -9
View File
@@ -3,10 +3,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@@ -26,7 +22,6 @@
export let edit = false;
export let direct = false;
export let connection = null;
let url = '';
@@ -37,6 +32,9 @@
let accessControl = {};
let name = '';
let description = '';
let enable = true;
let loading = false;
@@ -55,7 +53,7 @@
if (direct) {
const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token,
`${url}/${path}`
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
).catch((err) => {
toast.error($i18n.t('Connection failed'));
});
@@ -73,6 +71,10 @@
config: {
enable: enable,
access_control: accessControl
},
info: {
name,
description
}
}).catch((err) => {
toast.error($i18n.t('Connection failed'));
@@ -99,6 +101,10 @@
config: {
enable: enable,
access_control: accessControl
},
info: {
name: name,
description: description
}
};
@@ -112,6 +118,9 @@
key = '';
auth_type = 'bearer';
name = '';
description = '';
enable = true;
accessControl = null;
};
@@ -124,6 +133,9 @@
auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? '';
name = connection.info?.name ?? '';
description = connection.info?.description ?? '';
enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null;
}
@@ -225,12 +237,11 @@
</div>
<div class="flex-1 flex items-center">
<div class="text-sm">/</div>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={path}
placeholder={$i18n.t('openapi.json Path')}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
@@ -240,7 +251,7 @@
<div class="text-xs text-gray-500 mt-1">
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: `${url}/${path}`
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
@@ -280,6 +291,39 @@
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<AccessControl bind:accessControl />
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import { extractFrontmatter } from '$lib/utils';
export let show = false;
export let onImport = (e) => {};
export let onClose = () => {};
export let loadUrlHandler: Function = () => {};
export let successMessage: string = '';
let loading = false;
let url = '';
const submitHandler = async () => {
loading = true;
if (!url) {
toast.error($i18n.t('Please enter a valid URL'));
loading = false;
return;
}
const res = await loadUrlHandler(url).catch((err) => {
toast.error(`${err}`);
loading = false;
return null;
});
if (res) {
if (!successMessage) {
successMessage = $i18n.t('Function imported successfully');
}
toast.success(successMessage);
let func = res;
func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase();
const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted
if (frontmatter?.title) {
func.name = frontmatter.title;
}
func.meta = {
...(func.meta ?? {}),
description: frontmatter?.description ?? func.name
};
onImport(func);
show = false;
}
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-3 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="px-1">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('URL')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="url"
bind:value={url}
placeholder={$i18n.t('Enter the URL to import')}
required
/>
<!-- $i18n.t('Enter the URL of the function to import') -->
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Import')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
+48 -6
View File
@@ -13,6 +13,7 @@
exportFunctions,
getFunctionById,
getFunctions,
loadFunctionByUrl,
toggleFunctionById,
toggleGlobalById
} from '$lib/apis/functions';
@@ -32,6 +33,9 @@
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import XMark from '../icons/XMark.svelte';
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
import ImportModal from '../ImportModal.svelte';
const i18n = getContext('i18n');
@@ -40,6 +44,8 @@
let functionsImportInputElement: HTMLInputElement;
let importFiles;
let showImportModal = false;
let showConfirm = false;
let query = '';
@@ -196,6 +202,19 @@
</title>
</svelte:head>
<ImportModal
bind:show={showImportModal}
loadUrlHandler={async (url) => {
return await loadFunctionByUrl(localStorage.token, url);
}}
onImport={(func) => {
sessionStorage.function = JSON.stringify({
...func
});
goto('/admin/functions/create');
}}
/>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
@@ -215,15 +234,36 @@
bind:value={query}
placeholder={$i18n.t('Search Functions')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div>
<a
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
href="/admin/functions/create"
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
>
<Plus className="size-3.5" />
</a>
<div
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
>
<Plus className="size-3.5" />
</div>
</AddFunctionMenu>
</div>
</div>
</div>
@@ -447,7 +487,9 @@
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Functions')}</div>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Functions')} ({$functions.length})
</div>
<div class=" self-center">
<svg
@@ -0,0 +1,77 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Github from '$lib/components/icons/Github.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import Link from '$lib/components/icons/Link.svelte';
const i18n = getContext('i18n');
export let createHandler: Function;
export let importFromLinkHandler: Function;
export let onClose: Function = () => {};
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('Create')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => {
createHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<PencilSolid />
</div>
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
</button>
<button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={async () => {
importFromLinkHandler();
show = false;
}}
>
<div class=" self-center mr-2">
<Link />
</div>
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
</button>
</DropdownMenu.Content>
</div>
</Dropdown>
@@ -277,7 +277,7 @@ class Pipe:
await tick();
if (res) {
console.log('Code formatted successfully');
console.info('Code formatted successfully');
saveHandler();
}
@@ -62,7 +62,7 @@
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 w-full gap-2">
<div class="flex-1 relative">
<input
class=" outline-hidden w-full bg-transparent {pipeline ? 'pr-8' : ''}"
@@ -91,7 +91,7 @@
return;
}
console.log('Update embedding model attempt:', embeddingModel);
console.debug('Update embedding model attempt:', embeddingModel);
updateEmbeddingModelLoading = true;
const res = await updateEmbeddingConfig(localStorage.token, {
@@ -114,7 +114,7 @@
updateEmbeddingModelLoading = false;
if (res) {
console.log('embeddingModelUpdateHandler:', res);
console.debug('embeddingModelUpdateHandler:', res);
if (res.status === true) {
toast.success($i18n.t('Embedding model set to "{{embedding_model}}"', res), {
duration: 1000 * 10
@@ -124,6 +124,13 @@
};
const submitHandler = async () => {
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external' &&
RAGConfig.EXTERNAL_DOCUMENT_LOADER_URL === ''
) {
toast.error($i18n.t('External Document Loader URL required.'));
return;
}
if (RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika' && RAGConfig.TIKA_SERVER_URL === '') {
toast.error($i18n.t('Tika Server URL required.'));
return;
@@ -143,6 +150,14 @@
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' &&
!RAGConfig.DATALAB_MARKER_API_KEY
) {
toast.error($i18n.t('Datalab Marker API Key required.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' &&
(RAGConfig.DOCUMENT_INTELLIGENCE_ENDPOINT === '' ||
@@ -163,6 +178,16 @@
await embeddingModelUpdateHandler();
}
RAGConfig.ALLOWED_FILE_EXTENSIONS = (RAGConfig?.ALLOWED_FILE_EXTENSIONS ?? '')
.split(',')
.map((ext) => ext.trim())
.filter((ext) => ext !== '');
RAGConfig.DATALAB_MARKER_LANGS = RAGConfig.DATALAB_MARKER_LANGS.split(',')
.map((code) => code.trim())
.filter((code) => code !== '')
.join(', ');
const res = await updateRAGConfig(localStorage.token, RAGConfig);
dispatch('save');
};
@@ -185,7 +210,9 @@
onMount(async () => {
await setEmbeddingConfig();
RAGConfig = await getRAGConfig(localStorage.token);
const config = await getRAGConfig(localStorage.token);
config.ALLOWED_FILE_EXTENSIONS = (config?.ALLOWED_FILE_EXTENSIONS ?? []).join(', ');
RAGConfig = config;
});
</script>
@@ -246,7 +273,7 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5 flex flex-col w-full justify-between">
<div class="flex w-full justify-between">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium">
{$i18n.t('Content Extraction Engine')}
</div>
@@ -256,8 +283,10 @@
bind:value={RAGConfig.CONTENT_EXTRACTION_ENGINE}
>
<option value="">{$i18n.t('Default')}</option>
<option value="external">{$i18n.t('External')}</option>
<option value="tika">{$i18n.t('Tika')}</option>
<option value="docling">{$i18n.t('Docling')}</option>
<option value="datalab_marker">{$i18n.t('Datalab Marker API')}</option>
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
<option value="mistral_ocr">{$i18n.t('Mistral OCR')}</option>
</select>
@@ -275,11 +304,156 @@
</div>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker'}
<div class="my-0.5 flex gap-2 pr-2">
<SensitiveInput
placeholder={$i18n.t('Enter Datalab Marker API Key')}
required={false}
bind:value={RAGConfig.DATALAB_MARKER_API_KEY}
/>
</div>
<div class="flex justify-between w-full mt-2">
<div class="text-xs font-medium">
{$i18n.t('Languages')}
</div>
<input
class="text-sm bg-transparent outline-hidden"
type="text"
bind:value={RAGConfig.DATALAB_MARKER_LANGS}
placeholder={$i18n.t('e.g.) en,fr,de')}
/>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Significantly improves accuracy by using an LLM to enhance tables, forms, inline math, and layout detection. Will increase latency. Defaults to True.'
)}
placement="top-start"
>
{$i18n.t('Use LLM')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_USE_LLM} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t('Skip the cache and re-run the inference. Defaults to False.')}
placement="top-start"
>
{$i18n.t('Skip Cache')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_SKIP_CACHE} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Force OCR on all pages of the PDF. This can lead to worse results if you have good text in your PDFs. Defaults to False.'
)}
placement="top-start"
>
{$i18n.t('Force OCR')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_FORCE_OCR} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.'
)}
placement="top-start"
>
{$i18n.t('Paginate')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_PAGINATE} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Strip existing OCR text from the PDF and re-run OCR. Ignored if Force OCR is enabled. Defaults to False.'
)}
placement="top-start"
>
{$i18n.t('Strip Existing OCR')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_STRIP_EXISTING_OCR} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Disable image extraction from the PDF. If Use LLM is enabled, images will be automatically captioned. Defaults to False.'
)}
placement="top-start"
>
{$i18n.t('Disable Image Extraction')}
</Tooltip>
</div>
<div class="flex items-center">
<Switch bind:state={RAGConfig.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION} />
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
"The output format for the text. Can be 'json', 'markdown', or 'html'. Defaults to 'markdown'."
)}
placement="top-start"
>
{$i18n.t('Output Format')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DATALAB_MARKER_OUTPUT_FORMAT}
>
<option value="markdown">{$i18n.t('Markdown')}</option>
<option value="json">{$i18n.t('JSON')}</option>
<option value="html">{$i18n.t('HTML')}</option>
</select>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter External Document Loader URL')}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_URL}
/>
<SensitiveInput
placeholder={$i18n.t('Enter External Document Loader API Key')}
required={false}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_API_KEY}
/>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika'}
<div class="flex w-full mt-1">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Tika Server URL')}
bind:value={RAGConfig.TIKA_SERVER_URL}
/>
@@ -288,27 +462,38 @@
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling'}
<div class="flex w-full mt-1">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling Server URL')}
bind:value={RAGConfig.DOCLING_SERVER_URL}
/>
</div>
<div class="flex w-full mt-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Engine')}
bind:value={RAGConfig.DOCLING_OCR_ENGINE}
/>
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Describe Pictures in Documents')}
</div>
<div class="flex items-center relative">
<Switch bind:state={RAGConfig.DOCLING_DO_PICTURE_DESCRIPTION} />
</div>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Document Intelligence Endpoint')}
bind:value={RAGConfig.DOCUMENT_INTELLIGENCE_ENDPOINT}
/>
@@ -437,7 +622,7 @@
{#if embeddingEngine === 'openai'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={OpenAIUrl}
required
@@ -448,7 +633,7 @@
{:else if embeddingEngine === 'ollama'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={OllamaUrl}
required
@@ -471,7 +656,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
bind:value={embeddingModel}
placeholder={$i18n.t('Set embedding model')}
required
@@ -482,7 +667,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set embedding model (e.g. {{model}})', {
model: embeddingModel.slice(-40)
})}
@@ -639,7 +824,7 @@
{#if RAGConfig.RAG_RERANKING_ENGINE === 'external'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={RAGConfig.RAG_EXTERNAL_RERANKER_URL}
required
@@ -661,7 +846,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3'
})}
@@ -677,7 +862,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={RAGConfig.TOP_K}
@@ -692,7 +877,7 @@
<div class="self-center text-xs font-medium">{$i18n.t('Top K Reranker')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Enter Top K Reranker')}
bind:value={RAGConfig.TOP_K_RERANKER}
@@ -711,7 +896,7 @@
</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
@@ -731,6 +916,26 @@
</div>
</div>
{/if}
{#if RAGConfig.ENABLE_RAG_HYBRID_SEARCH === true}
<div class="mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">
{$i18n.t('Weight of BM25 Retrieval')}
</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
step="0.01"
placeholder={$i18n.t('Enter BM25 Weight')}
bind:value={RAGConfig.HYBRID_BM25_WEIGHT}
autocomplete="off"
min="0.0"
max="1.0"
/>
</div>
</div>
{/if}
{/if}
<div class=" mb-2.5 flex flex-col w-full justify-between">
@@ -760,6 +965,26 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Allowed File Extensions')}</div>
<div class="flex items-center relative">
<Tooltip
content={$i18n.t(
'Allowed file extensions for upload. Separate multiple extensions with commas. Leave empty for all file types.'
)}
placement="top-start"
>
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('e.g. pdf, docx, txt')}
bind:value={RAGConfig.ALLOWED_FILE_EXTENSIONS}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Max Upload Size')}</div>
<div class="flex items-center relative">
@@ -770,7 +995,7 @@
placement="top-start"
>
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Leave empty for unlimited')}
bind:value={RAGConfig.FILE_MAX_SIZE}
@@ -791,7 +1016,7 @@
placement="top-start"
>
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Leave empty for unlimited')}
bind:value={RAGConfig.FILE_MAX_COUNT}
@@ -18,6 +18,7 @@
import { compareVersion } from '$lib/utils';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext('i18n');
@@ -58,10 +59,10 @@
};
});
console.log(version);
console.info(version);
updateAvailable = compareVersion(version.latest, version.current);
console.log(updateAvailable);
console.info(updateAvailable);
};
const updateLdapServerHandler = async () => {
@@ -83,7 +84,7 @@
if (res) {
saveHandler();
} else {
toast.error(i18n.t('Failed to update settings'));
toast.error($i18n.t('Failed to update settings'));
}
};
@@ -305,6 +306,31 @@
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
</div>
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Title')}
</div>
<Textarea
rows={2}
placeholder={$i18n.t(
'Enter a title for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_TITLE}
/>
</div>
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Pending User Overlay Content')}
</div>
<Textarea
placeholder={$i18n.t(
'Enter content for the pending user info overlay. Leave empty for default.'
)}
bind:value={adminConfig.PENDING_USER_OVERLAY_CONTENT}
/>
</div>
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
@@ -559,6 +585,13 @@
/>
</div>
</div>
<div class="flex justify-between items-center text-xs">
<div class=" font-medium">Validate certificate</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.validate_cert} />
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
@@ -625,6 +658,16 @@
<Switch bind:state={adminConfig.ENABLE_USER_WEBHOOKS} />
</div>
<div class="mb-2.5">
<div class=" self-center text-xs font-medium mb-2">
{$i18n.t('Response Watermark')}
</div>
<Textarea
placeholder={$i18n.t('Enter a watermark for the response. Leave empty for none.')}
bind:value={adminConfig.RESPONSE_WATERMARK}
/>
</div>
<div class="mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>
@@ -198,14 +198,14 @@
2
);
} catch (e) {
console.log(e);
console.error(e);
}
}
requiredWorkflowNodes = requiredWorkflowNodes.map((node) => {
const n = config.comfyui.COMFYUI_WORKFLOW_NODES.find((n) => n.type === node.type) ?? node;
console.log(n);
console.debug(n);
return {
type: n.type,
@@ -1,20 +1,26 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { getBackendConfig, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { getBackendConfig, getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { setDefaultPromptSuggestions } from '$lib/apis/configs';
import { config, models, settings, user } from '$lib/stores';
import { config, settings, user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { banners as _banners } from '$lib/stores';
import type { Banner } from '$lib/types';
import { getBaseModels } from '$lib/apis/models';
import { getBanners, setBanners } from '$lib/apis/configs';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Banners from './Interface/Banners.svelte';
const dispatch = createEventDispatcher();
@@ -42,6 +48,7 @@
const updateInterfaceHandler = async () => {
taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await updateBanners();
@@ -49,6 +56,7 @@
};
onMount(async () => {
await init();
taskConfig = await getTaskConfig(localStorage.token);
promptSuggestions = $config?.default_prompt_suggestions ?? [];
@@ -58,9 +66,40 @@
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
let workspaceModels = null;
let baseModels = null;
let models = null;
const init = async () => {
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, null, false);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
is_active: true
};
}
});
console.debug('models', models);
};
</script>
{#if taskConfig}
{#if models !== null && taskConfig}
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
@@ -74,8 +113,8 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-1 font-medium flex items-center">
<div class=" text-xs mr-1">{$i18n.t('Set Task Model')}</div>
<div class=" mb-2 font-medium flex items-center">
<div class=" text-xs mr-1">{$i18n.t('Task Model')}</div>
<Tooltip
content={$i18n.t(
'A task model is used when performing tasks such as generating titles for chats and web search queries'
@@ -100,32 +139,70 @@
<div class=" mb-2.5 flex w-full gap-2">
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('Local Models')}</div>
<div class=" text-xs mb-1">{$i18n.t('Local Task Model')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={taskConfig.TASK_MODEL}
placeholder={$i18n.t('Select a model')}
on:change={() => {
if (taskConfig.TASK_MODEL) {
const model = models.find((m) => m.id === taskConfig.TASK_MODEL);
if (model) {
if (model?.access_control !== null) {
toast.error(
$i18n.t(
'This model is not publicly available. Please select another model.'
)
);
}
taskConfig.TASK_MODEL = model.id;
} else {
taskConfig.TASK_MODEL = '';
}
}
}}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models.filter((m) => m.owned_by === 'ollama') as model}
{#each models as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
{model?.connection_type === 'local' ? `(${$i18n.t('Local')})` : ''}
</option>
{/each}
</select>
</div>
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('External Models')}</div>
<div class=" text-xs mb-1">{$i18n.t('External Task Model')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={taskConfig.TASK_MODEL_EXTERNAL}
placeholder={$i18n.t('Select a model')}
on:change={() => {
if (taskConfig.TASK_MODEL_EXTERNAL) {
const model = models.find((m) => m.id === taskConfig.TASK_MODEL_EXTERNAL);
if (model) {
if (model?.access_control !== null) {
toast.error(
$i18n.t(
'This model is not publicly available. Please select another model.'
)
);
}
taskConfig.TASK_MODEL_EXTERNAL = model.id;
} else {
taskConfig.TASK_MODEL_EXTERNAL = '';
}
}
}}
>
<option value="" selected>{$i18n.t('Current Model')}</option>
{#each $models as model}
{#each models as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
{model.name}
{model?.connection_type === 'local' ? `(${$i18n.t('Local')})` : ''}
</option>
{/each}
</select>
@@ -283,9 +360,9 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" {banners.length > 0 ? ' mb-3' : ''}">
<div class="mb-2.5 flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-sm">
{$i18n.t('Banners')}
</div>
@@ -321,69 +398,13 @@
</button>
</div>
<div class=" flex flex-col space-y-1">
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div
class="flex flex-row flex-1 border rounded-xl border-gray-100 dark:border-gray-850"
>
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-hidden"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900"
>{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="px-2"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
</div>
<Banners bind:banners />
</div>
{#if $user?.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">
<div class=" self-center text-sm">
{$i18n.t('Default Prompt Suggestions')}
</div>
@@ -466,6 +487,111 @@
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
<div class="flex items-center justify-end space-x-2 mt-2">
<input
id="prompt-suggestions-import-input"
type="file"
accept=".json"
hidden
on:change={(e) => {
const files = e.target.files;
if (!files || files.length === 0) {
return;
}
console.log(files);
let reader = new FileReader();
reader.onload = async (event) => {
try {
let suggestions = JSON.parse(event.target.result);
suggestions = suggestions.map((s) => {
if (typeof s.title === 'string') {
s.title = [s.title, ''];
} else if (!Array.isArray(s.title)) {
s.title = ['', ''];
}
return s;
});
promptSuggestions = [...promptSuggestions, ...suggestions];
} catch (error) {
toast.error($i18n.t('Invalid JSON file'));
return;
}
};
reader.readAsText(files[0]);
e.target.value = ''; // Reset the input value
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
type="button"
on:click={() => {
const input = document.getElementById('prompt-suggestions-import-input');
if (input) {
input.click();
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Prompt Suggestions')}
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{#if promptSuggestions.length}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
type="button"
on:click={async () => {
let blob = new Blob([JSON.stringify(promptSuggestions)], {
type: 'application/json'
});
saveAs(blob, `prompt-suggestions-export-${Date.now()}.json`);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Prompt Suggestions')} ({promptSuggestions.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
{/if}
</div>
</div>
{/if}
</div>
@@ -480,4 +606,8 @@
</button>
</div>
</form>
{:else}
<div class=" h-full w-full flex justify-center items-center">
<Spinner />
</div>
{/if}
@@ -0,0 +1,101 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
export let banners = [];
let sortable = null;
let bannerListElement = null;
const positionChangeHandler = () => {
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
child.id.replace('banner-item-', '')
);
// Sort the banners array based on the new order
banners = bannerIdOrder.map((id) => {
const index = banners.findIndex((banner) => banner.id === id);
return banners[index];
});
};
$: if (banners) {
init();
}
const init = () => {
if (sortable) {
sortable.destroy();
}
if (bannerListElement) {
sortable = Sortable.create(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
};
</script>
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-center">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="pr-3"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{/each}
</div>
@@ -20,6 +20,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner';
@@ -33,6 +34,7 @@
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import { copyToClipboard } from '$lib/utils';
let shiftKey = false;
@@ -166,7 +168,7 @@
hidden: !(model?.meta?.hidden ?? false)
};
console.log(model);
console.debug(model);
toast.success(
model.meta.hidden
@@ -181,6 +183,17 @@
upsertModelHandler(model);
};
const copyLinkHandler = async (model) => {
const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/?model=${encodeURIComponent(model.id)}`);
if (res) {
toast.success($i18n.t('Copied link to clipboard'));
} else {
toast.error($i18n.t('Failed to copy link'));
}
};
const exportModelHandler = async (model) => {
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
@@ -271,6 +284,18 @@
bind:value={searchValue}
placeholder={$i18n.t('Search Models')}
/>
{#if searchValue}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
searchValue = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
</div>
@@ -381,6 +406,9 @@
hideHandler={() => {
hideModelHandler(model);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
onClose={() => {}}
>
<button
@@ -495,7 +523,7 @@
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Presets')}
{$i18n.t('Export Presets')} ({models.length})
</div>
<div class=" self-center">

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